diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3adf401b..25dfad89 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,12 +1,17 @@ { + // ANCHOR - script must have LF line endings to work in the container, so make sure to configure your editor to use LF for this file. IT SHOULD NOT BE CRLF. "name": "Vantage Point", + // dockerComposeFile tells VS Code to use Compose instead of building a single container. // 'image' and 'dockerComposeFile' are mutually exclusive — don't use both. // @NeoMachabaUP : i am still figuring out how this differs dockerfile to a focus on dev container, but for now we are using docker compose to build the dev container and run the app in it. + + // services (app + db). A standalone Dockerfile would only give you one container. "dockerComposeFile": "docker-compose.yml", - // dockerComposeFile tells VS Code to use Compose instead of building a single container. "service": "app", "workspaceFolder": "/workspaces", + // Features are pre-built install scripts that layer on top of the base image. + // Node is not included in the Python base image so we add it here. "features": { "ghcr.io/devcontainers/features/node:1": { "version": "22" @@ -23,8 +28,9 @@ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", // database tools; Microsoft maintained PostgreSQL extension - //"ms-ossdata.vscode-postgresql", not working currently so using alt + //"ms-ossdata.vscode-pgsql", not working currently so using alt "ckolkman.vscode-postgres", + "cweijan.vscode-postgresql-client2", // this is a nice alternative to the Chris Kolkman one, it has a better UI and more features, recommended by @Vele189 // Misc ; WE need to better describe these extensions and why we need them in the README "tamasfe.even-better-toml", "ms-azuretools.vscode-docker" @@ -32,9 +38,8 @@ } }, - // 8000 = FastAPI, 5173 = Vite, 5432 = Postgres (for DBeaver or the VS Code extension on the host) + // 8000 = FastAPI Backend , 5173 = Vite frontend dev server, 5432 = Postgres (for DBeaver or the VS Code extension on the host) // @NeoMachabaUP : we are using 3000 for the frontend, but we can change this if we want to use 5173 instead. I just wanted to avoid confusion with the default Vite port. - // @NeoMachabaUP : @Vele189 could you have a look here please "forwardPorts": [8000, 3000, 5432, 5173], "postCreateCommand": "bash /workspaces/.devcontainer/post-create.sh", "postStartCommand": "/bin/bash /workspaces/.devcontainer/start-services.sh" diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index c6a752a5..54308d89 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -5,24 +5,29 @@ services: image: mcr.microsoft.com/devcontainers/python:3.12 volumes: - ..:/workspaces:cached + - pip-cache:/home/vscode/.cache/pip + # persists pip download cache + - npm-cache:/home/vscode/.npm + # persists npm download cache # Keeps the container running so you can code inside it command: sleep infinity environment: # This points Python to the 'db' service below - DATABASE_URL: postgresql+asyncpg://riot_user:riot_password@db:5432/riot_db + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} ports: - "5173:5173" # Vite (frontend) - "8000:8000" # FastAPI (backend) - "3000:3000" # Frontend (React) + #WE Can remove 3000 if we are not using React, keeping it here for now as it is in the techstack that we use it # The PostgreSQL Database db: image: postgres:15 restart: always environment: - POSTGRES_USER: riot_user - POSTGRES_PASSWORD: riot_password - POSTGRES_DB: riot_db + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} # Persistent storage: data stays even if you stop the container volumes: - postgres_data:/var/lib/postgresql/data @@ -30,4 +35,6 @@ services: - "5432:5432" volumes: - postgres_data: \ No newline at end of file + postgres_data: + pip-cache: # pip will reuse downloaded wheels instead of fetching them again + npm-cache: # npm will reuse downloaded packages instead of fetching them again \ No newline at end of file diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index a5ceb5f7..f4a0689a 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -7,6 +7,7 @@ sudo apt-get update -qq && sudo apt-get install -y postgresql-client # Backend — install Python dependencies cd /workspaces/backend pip install -r requirements.txt +pip install -r requirements-dev.txt # Frontend — install Node dependencies cd /workspaces/frontend diff --git a/.devcontainer/start-services.sh b/.devcontainer/start-services.sh index 8e94d6a9..7e463d3f 100644 --- a/.devcontainer/start-services.sh +++ b/.devcontainer/start-services.sh @@ -1,33 +1,33 @@ -# #!/bin/bash +#!/bin/bash +set -e -# # Start frontend -# cd /workspaces/frontend -# nohup npm run dev -- --host 0.0.0.0 > /tmp/vite.log 2>&1 & +# --- Load nvm so npm and node are in the PATH --- +export NVM_DIR="/usr/local/share/nvm" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" -# # Start backend -# cd /workspaces/backend -# nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/uvicorn.log 2>&1 & +# Also ensure user-installed Python packages (uvicorn) are available +export PATH="/home/vscode/.local/bin:$PATH" -# echo "Frontend and backend started." -#!/bin/bash -set -e +export CI=true echo "=== Starting dev services ===" # --- Frontend (Vite) --- cd /workspaces/frontend echo "Starting frontend..." -nohup npm run dev -- --host 0.0.0.0 > /tmp/vite.log 2>&1 & +setsid npm run dev -- --host 0.0.0.0 /tmp/vite.log 2>&1 & VITE_PID=$! echo "Vite PID: $VITE_PID" # --- Backend (FastAPI) --- cd /workspaces/backend echo "Starting backend..." -nohup uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload > /tmp/uvicorn.log 2>&1 & +setsid uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload /tmp/uvicorn.log 2>&1 & UVICORN_PID=$! echo "Uvicorn PID: $UVICORN_PID" echo "=== Both services launched ===" +sleep 2 echo "Frontend log: /tmp/vite.log" -echo "Backend log: /tmp/uvicorn.log" \ No newline at end of file +echo "Backend log: /tmp/uvicorn.log" +head -5 /tmp/vite.log \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1045b169 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# .gitattributes +# Force LF line endings for shell scripts — CRLF breaks bash in Linux containers +*.sh text eol=lf +*.json text eol=lf +*.py text eol=lf +*.md text eol=lf \ No newline at end of file diff --git a/.github/docs/API-Call-Documentation.md b/.github/docs/API-Call-Documentation.md new file mode 100644 index 00000000..8f73f87d --- /dev/null +++ b/.github/docs/API-Call-Documentation.md @@ -0,0 +1,46 @@ +RIOT API CALL DOCUMENTATION EXAMPLE + +API URLs Example: + Mostly contain the data about which URL used when you Postman to get data back + Riot API. Will only show output on payload that is not too big. + + Each of these requires a X-Riot-Token with the API Key as its value. +Example: + + +Player ID(Needs to be retrieved first, used different API to obtain this) + Basis: /riot/account/v1/accounts/by-riot-id/{gameName}/{tagLine} + Example: https://americas.api.riotgames.com/riot/account/v1/accounts/by-riot-id/Sn1per1/NA2 +Output: +{ + "puuid": "_1Zwg23PL6uCtrHwZ5r-hiswulAY8XqK0T0ni2KrmrgAg5Cbct1loAMQ4QEzkVfTeLy5O4nKeqJ-Aw", + "gameName": "Sn1per1", + "tagLine": "NA2" +} + + + +Get Matches Played based on Player ID +Basis: /lol/match/v5/matches/{matchId} +Example: https://americas.api.riotgames.com/lol/match/v5/matches/by-puuid/_1Zwg23PL6uCtrHwZ5r-hiswulAY8XqK0T0ni2KrmrgAg5Cbct1loAMQ4QEzkVfTeLy5O4nKeqJ-Aw/ids?start=0&count=5 +Output: +[ + "NA1_5549204828", + "NA1_5549193077", + "NA1_5545315584", + "NA1_5545295750", + "NA1_5545261585" +] + +Get Match Details at the end of match +Basis: /lol/match/v5/matches/{matchId} +Example: https://americas.api.riotgames.com/lol/match/v5/matches/NA1_5549204828?api_key=RGAPI-b567c1ba-1c25-4373-88a5-489ef5dcb5e0 +Output too big to paste here. + + + + +Get match details based on Timeline +Basis: /lol/match/v5/matches/{matchId}/timeline +Example:https://americas.api.riotgames.com/lol/match/v5/matches/NA1_5549204828/timeline +Output too big to paste here diff --git a/.github/docs/API_Docs.md b/.github/docs/API_Docs.md new file mode 100644 index 00000000..4a47243e --- /dev/null +++ b/.github/docs/API_Docs.md @@ -0,0 +1,256 @@ +Api Endpoints Documentation + +League of legends + +This will be the data sent to the frontend + +Base URL: http://127.0.0.1:8000 + +Version: 1.0.0 + +Last Updated: 13 May 2026 + +Table of Contents +1 Authentication Endpoints + +2 User Profile Endpoints + +3 Riot & Match Data Endpoints + +1.1 Register User +Endoint: POST /api/auth/register + +Description: Creates a new user Account +This might change later on as we still need to decide on the riot api. + +Request Body: +{ + "username": "Sn1per1", + "email": "player@example.com", + "password": "SecurePass123!" +} + + +Response Body: +{ + "message": "User registered successfully." +} + + +1.2 Login User +Endpoint: /api/auth/login + +Description: Authenticates a user and returns Cognito JWT tokens. + +Request Body: +{ + "password": "securepassword123", + "username": "Sn1per1" +} + +Response Body: +{ + "AccessToken": "eyJraWQiOiJFMnk3eDc...", + "ExpiresIn": 3600, + "TokenType": "Bearer", + "RefreshToken": "eyJjdHkiOiJKV1QiLCJ...", + "IdToken": "eyJraWQiOiJsMmVHZ0g..." +} + +1.3 Confirm Email +Endpoint: Post /api/auth/confirm +Description: Verifies the user's email registration using the Cognito confirmation code + +Request Body: +{ + "username": "Sn1per1", + "confirmation_code": "123456" +} + +Response Body: +{ + "status": "success" +} + +1.4 Logout User +Endpoint: /api/auth/logout +Description: Globally invalidates the user's active session across all devices. +Requires Authentication: Bearer + +Responde Body: +{ + "message": "Successfully logged out from all devices." +} + +Pending Backend Implementation +The following authentication endpoints exist in design but are missing from the current codebase: +POST /api/auth/resend-code (Resends registration code) +POST /api/auth/forgot-password (Triggers password reset flow) + +1.5 Resend Verification Code +EndPoint: POST /api/auth/resend-code + +Description: Resends email verification code + +Request Body: +{ + "username": "Sn1per1" +} +Response Body: +{ + "status": "success", + "message": "Verification code sent successfully", + "data": { + "username": "Sn1per1", + "email": "p***@example.com", + "resend_available_after": "2026-05-13T10:35:00Z" + } +} + +1.5 Forgot Password +Endpoint: POST /api/auth/forgot-password + +Description: Requests password reset code via email. + +Request Body +{ + "username": "Sn1per1" +} + +Response Body: +{ + status": "success", + "message": "If an account exists, a password reset code has been sent to your email", + "data": { + "code_expires_in_minutes": 10 + } +} + +1.6 Delete Account +Endpoint: /api/profile + +Description: Delete a Users Account + +Response Body: +{} + +1.7 Undo Delete Account +Endpoint: /api/profile + +Description: Delete a Users Account + +2 User Profile Endpoints +All endpoints in this section require an authentication header: Authorization: Bearer . + +2.1 Get Profile Summary +Endpoint: Get /api/profile +Description: Retrieves the authenticated user's profile details and a parsed aggregate performance summary. +Still up to be chnaged is mock data at the moment +Response Body: +{ + "uuid": "b0fc69dc-40a1-704a-5302-a6c936519de9", + "username": "Sn1per1", + "total_matches": 142, + "player_summary": { + "most_played_character": "Jinx", + "common_mistakes": [ + "Low vision score", + "Overextending late game" + ], + "avg_kda": "8.4 / 4.2 / 6.1", + "win_rate": "54%" + } +} + +2.2 Update Riot Key +Endpoint Put /api/profile/riot-key +Description: Temporarily updates the Riot API key configuration for the user's current session. + +Request Body: +{ + "riot_api_key": "RGAPI-xxx..." +} + +Response Body: +{ + "message": "Riot API Key updated successfully for this session.", + "user": "Sn1per1", + "status": "mock_verified" +} + +2.3 Delete Account +Endpoint Delete /api/profile +Description:Places the authenticated account into a 30-day grace period queue before permanent deletion. + +Response Body: +{ + "message": "Account marked for deletion. You have 30 days to undo this action." +} + +2.4 Undo Delete Account +Endpoint Post /api/profile/undo-delete + +Response Bidy: +{ + "message": "Account deletion cancelled successfully" +} + +3 Riot & Match Data Endpoints + +3.2 Get Riot Match IDs by PUUID +Endpoint: Get /api/riot/matches/{puuid} +Description: Hits the live Riot API engine to pull historical match ID strings associated with a specific Player UUID. +Query Params: count(optional, default=5) + +Response Body: +[ + NA1_51029301", + "NA1_51028442", + "NA1_51027119" +] + +3.3 Get Single Player Filtered Match Summary +Endpoint Get /api/matches/{match_id}/filtered +Description: Resolves full match details directly through Riot's servers and outputs an optimized, lightweight runtime payload focused on a singular player's stats + +Query Parameters: + +puuid (string, required): The target player's exact PUUID. + + +Response Body: +{ + "match_id": "NA1_51029301", + "game_mode": "CLASSIC", + "game_duration_seconds": 1930, + "game_end_timestamp": "2026-05-13T14:22:00Z", + "target_player": { + "puuid": "0d2c7b5e-riot-puuid-example-123456", + "riot_id": "Sn1per1#NA1", + "champion_name": "Jinx", + "champion_id": 222, + "team_position": "BOTTOM", + "win": true, + "kills": 12, + "deaths": 3, + "assists": 8, + "kda": 6.67, + "gold_earned": 15400, + "total_damage_dealt_to_champions": 28450, + "vision_score": 18, + "creep_score": { + "total_minions_killed": 235, + "neutral_minions_killed": 12, + "cs_per_minute": 7.7 + }, + "items": [ + 3031, + 3046, + 3006, + 1055, + 3072, + 0, + 3363 + ] + } +} \ No newline at end of file diff --git a/.github/docs/Architecture Diagram.pdf b/.github/docs/Architecture Diagram.pdf new file mode 100644 index 00000000..19718831 Binary files /dev/null and b/.github/docs/Architecture Diagram.pdf differ diff --git a/.github/docs/BACKEND_DEV.md b/.github/docs/Backend-Development-Guide.md similarity index 100% rename from .github/docs/BACKEND_DEV.md rename to .github/docs/Backend-Development-Guide.md diff --git a/.github/docs/Brand-Style.md b/.github/docs/Brand-Style.md new file mode 100644 index 00000000..300d41af --- /dev/null +++ b/.github/docs/Brand-Style.md @@ -0,0 +1,226 @@ +# Brand Style + +The brand style defines the visual identity of Vantage Point and ensures a consistent, professional appearance across all interfaces. This document reflects what is **implemented in the codebase** today (`frontend/src`), not aspirational guidelines. + +--- + +## Color Palette + +Vantage Point uses two complementary colour systems: + +1. **Design tokens** — CSS variables in `frontend/src/styles/theme.css`, consumed by shadcn/ui components via Tailwind (`bg-primary`, `text-muted-foreground`, etc.). +2. **Figma / screen-specific hex values** — hard-coded on auth screens, the dashboard shell, and match views (primarily Inter-based layouts). + +### Semantic tokens (light mode) + +Defined in `:root` in `theme.css`: + +| Role | Value | Usage | +|------|-------|--------| +| Background | `#ffffff` | Page and card surfaces | +| Foreground | `oklch(0.145 0 0)` | Primary text | +| Primary | `#030213` | Primary actions, emphasis | +| Primary foreground | `oklch(1 0 0)` | Text on primary | +| Secondary | `oklch(0.95 0.0058 264.53)` | Secondary surfaces | +| Muted | `#ececf0` / muted-foreground `#717182` | Subtle UI, secondary text | +| Accent | `#e9ebef` | Hover / highlight surfaces | +| Destructive | `#d4183d` | Errors, destructive actions | +| Border | `rgba(0, 0, 0, 0.1)` | Dividers and outlines | +| Input background | `#f3f3f5` | Form fields (shadcn `Input`) | +| Ring (focus) | `oklch(0.708 0 0)` | Focus rings on interactive controls | +| Chart 1–5 | OKLCH palette | Radar chart and data viz | + +A **dark mode** palette is also defined under `.dark` in the same file (used by shadcn primitives). The main app screens (auth, dashboard) currently render in **light mode** with explicit hex colours rather than toggling `.dark`. + +### Application UI colours (hex) + +Used across login, register, dashboard, profile, and matches: + +| Role | HEX | Where used | +|------|-----|------------| +| Body text | `#1e1e1e` | Labels, match rows, profile copy | +| Strong emphasis | `#0b0b0b` | Auth links (e.g. “Sign up”) | +| Secondary text | `#525252`, `#757575` | Section labels, metadata | +| Placeholder | `#b3b3b3` | Auth input placeholders | +| Borders | `#d9d9d9`, `#eee` | Inputs, match list dividers | +| Primary button | `#2c2c2c` (hover `#3c3c3c`) | Sign in / register CTAs | +| Button label | `#f5f5f5` | Text on dark CTAs | +| Hover surface | `#f5f5f5` | Profile edit button hover | +| Victory | `#1e7e34` | Win outcome, match result | +| Defeat / error | `#c44a4a` | Loss outcome, API errors | +| Blue side (LoL) | `#4a7fd4` | Match detail team 100 | +| Red side background | `#fce8e8` | Defeat team chip in match detail | +| Avatar fallback | `#404040` | Initials when no profile image | +| Sidebar panel | `rgba(117, 117, 117, 0.12)` | Dashboard nav background | +| Landing backdrop | `#000000` | Full-screen landing (`LandingPage`) | +| Loading brand text | `#0f172a` | Loading screen wordmark | + +**Loading animation accents** (`theme.css` utilities): `#1d4ed8`, `#0f766e`, `#6d28d9`, `#0e7490` cycle in `animate-vantage-pulse`. + +### Layout tokens + +| Token | Value | Purpose | +|-------|-------|---------| +| `--vp-layout-max` | `1512px` | Max dashboard artboard width | +| `--vp-dashboard-header` | `94px` | Fixed header height | +| `--vp-sidebar-width` | `220px` | Sidebar width | +| `--radius` | `0.625rem` (10px) | Default border radius; sm/md/lg/xl derived | + +### Accessibility (colour) + +- Auth inputs use `#1e1e1e` on white with `#d9d9d9` borders; focus darkens border to `#2c2c2c`. +- Victory (`#1e7e34`) and defeat (`#c44a4a`) are distinct for outcome scanning. +- shadcn controls use `focus-visible:ring-[3px]` with `--ring` for keyboard focus visibility. + +--- + +## Typography + +Fonts are loaded from Google Fonts in `frontend/src/styles/fonts.css` and applied per screen. + +### Font families + +| Family | Role | Implementation | +|--------|------|----------------| +| **Sarina** | Brand wordmark “Vantage Point” | `font-sarina` token in `theme.css`; `font-sarina` class on login, register, loading, Riot ID screens | +| **Inter** | Primary UI typeface | `font-['Inter:Regular',sans-serif]` (body), `Inter:Semi_Bold` (headings, labels, buttons) | +| **Geist** | Featured-game card badges | `font-['Geist:Medium',sans-serif]` at 14px on `FeaturedGameCard` | +| **Sora** | Loaded globally | Available via `fonts.css`; not heavily used in current screens | + +### Sizes and weights + +| Element | Size | Weight | Line height | +|---------|------|--------|-------------| +| Root / `html` | `16px` (`--font-size`) | — | — | +| Brand title (auth) | `clamp(20px, 2.5vw, 32px)` | Sarina regular | `leading-normal` | +| Brand title (loading) | `clamp(22px, 4.2vw, 40px)` | Sarina regular | `leading-tight` | +| Body / inputs | `16px` | Inter 400 | `1.4` or `leading-none` | +| Dashboard nav | `14px` | Inter 400 | `1.4` | +| Section headings (profile) | `14px` uppercase | Inter 600 | — | +| Profile display name | `28px` | Inter 600 | `leading-tight` | +| Match stat labels | `11px` uppercase | Inter 500 | `tracking-wide` | +| shadcn `Button` / `Input` | `text-sm` / `text-base` | medium (500) per `theme.css` base layer | `1.5` | + +Base heading scale in `theme.css` (`h1`–`h4`) uses `--font-weight-medium` (500) with Tailwind text scale variables; Figma screens override with explicit Inter sizes. + +--- + +## Logo and Iconography + +### Logo + +- **Primary mark:** WebP asset `798001aef0b2686ac929f8c349135d3326ab65bb.webp` (imported in `Login.tsx`, `Register.tsx`, `RiotId.tsx`, `LoadingPage.tsx`, `Group14.tsx`). +- **Sizing:** + - Auth / Riot ID: `clamp(120px, 18vw, 200px)` square, with Sarina wordmark below. + - Dashboard header: `97×99px` absolute top-left (`Group14.tsx`). + - Loading: responsive clamp, paired with animated wordmark. +- **Wordmark:** “Vantage Point” in Sarina, black on light auth screens, `#0f172a` on loading. + +### Favicon + +- `index.html` references `/favicon.svg` as the site icon; page title is **Vantage Point**. + +### Icons + +| Source | Usage | +|--------|--------| +| **Lucide React** | UI chrome (chevrons, checks, carousel controls, achievement mapping in `achievementIcons.ts`) | +| **Inline SVG** (`svg-*.ts` in imports) | Dashboard sidebar toggle, decorative vectors | +| **Figma-exported PNG/WebP** | Profile featured game (`league-wild-rift-*.png`), `icon-lightning-bolt.png`, `icon-time-machine.png` | +| **Social providers** | Google, Apple, Riot Games WebP buttons on auth (60×60px, `alt` describes action) | +| **Riot Data Dragon** | Champion and item images via `ddragon.ts` | + +### Placement rules (as implemented) + +- Logo is **centred above** auth forms; on dashboard it is **fixed top-left** with account menu top-right. +- Decorative carousel/background images use `alt=""` (presentational); interactive controls and social buttons have descriptive `alt` text. +- Achievement icons sit in a grid with optional count badges (`ProfileView.tsx`). + +--- + +## Design Principles + +Patterns observed in the implementation: + +| Principle | How it appears in Vantage Point | +|-----------|----------------------------------| +| **Consistency** | Shared auth input class (`authInputClassName`), shared logo asset, repeated dashboard layout constants (`dashboardLayout.ts`, `Group14.tsx`) | +| **Simplicity** | White dashboard canvas, minimal sidebar (Matches / Analysis / Settings labels), clear win/loss colour coding | +| **Responsiveness** | `clamp()` typography and logo on auth; match list grid adds columns at `sm:` breakpoint; sidebar collapses with animated width/position | +| **Accessibility** | Radix primitives, ARIA on navigation and match rows, focus rings on shadcn controls (see Accessibility) | +| **Brand personality** | League/Arcane full-bleed wallpapers on auth; Sarina script wordmark; subtle loading animations (`animate-vantage-pulse`, `animate-vantage-breathe`) | +| **Design–dev alignment** | Figma imports under `frontend/src/landing/imports/`; profile assets documented as Figma exports in `assets/profile/index.ts` | + +--- + +## UI Component Styling + +### Auth (login / register / Riot ID) + +- **Inputs:** 8px radius, 16px horizontal padding, `#d9d9d9` border, transparent background, Inter 16px (`authInputClassName` in `Login.tsx` / `Register.tsx`). +- **Primary CTA:** Full-width, 58px height, `#2c2c2c` background, `#f5f5f5` label, disabled at 60% opacity. +- **Social login:** Three 60×60 image buttons in a centred row. +- **Background carousel:** Dot tablist (`role="tablist"`), 8px dots, active `bg-black` / inactive 30% opacity. + +### Dashboard shell (`Group14.tsx`) + +- **Frame:** White full-viewport background, min-width fluid. +- **Sidebar:** 220px wide, 400px tall panel, 15px radius, grey translucent fill; nav items 47px tall, 10px radius white pills for active state. +- **Header:** 94px (`--vp-dashboard-header`); content offset when sidebar open (`DASHBOARD_CONTENT_LEFT_OPEN` = 299px). +- **Toggle:** 24px icon button with `aria-expanded`, `aria-controls="dashboard-sidebar"`. + +### shadcn/ui library (`frontend/src/landing/app/components/ui/`) + +Shared primitives for newer or composable UI: + +| Component | Styling summary | +|-----------|-----------------| +| **Button** | Variants: default, destructive, outline, secondary, ghost, link; sizes sm/default/lg/icon; `rounded-md`, `focus-visible:ring-[3px]` | +| **Input / Textarea / Select** | `h-9`, `rounded-md`, `bg-input-background`, ring on focus, `aria-invalid` destructive styling | +| **Dialog** | Overlay `bg-black/50`, animated open/close (Radix) | +| **Card, Badge, Avatar, Chart** | Token-driven colours; chart uses theme chart-1–5 | +| **Dropdown / Navigation menu** | Accent hover states, keyboard focus rings | + +Auth screens use **custom Figma buttons**; dashboard match/profile views mix **custom layout** with selective shadcn pieces (e.g. `Avatar`, `Button` in profile editor). + +### Match list and detail + +- **List row:** CSS grid, bordered with `#eee`, tabular numerals for stats. +- **Outcome:** Green `#1e7e34` / red `#c44a4a` semibold labels. +- **Detail:** Team colours blue/red; result banner uses same win/loss palette. + +--- + +## Accessibility + +Implemented practices (targeting WCAG-oriented behaviour): + +| Area | Implementation | +|------|----------------| +| **Language** | `` in `index.html` | +| **Keyboard focus** | shadcn components: `focus-visible:border-ring`, `focus-visible:ring-[3px]`; auth inputs: visible border change on focus | +| **Screen readers** | `aria-label` on dashboard sidebar toggle, nav (“Matches”), profile sections (“Performance radar”, “Achievements”, etc.); `aria-current="page"` for active nav; match rows expose `matchRowAriaLabel()`; carousel previous/next use `sr-only` text; login errors use `role="alert"` | +| **Semantic structure** | Match list uses `role="row"` / `role="columnheader"`; carousel uses `role="region"` and `aria-roledescription` | +| **Forms** | Labels associated with fields on auth screens; `aria-invalid` styling on shadcn inputs when invalid | +| **Motion** | CSS animations on loading screen; no `prefers-reduced-motion` override yet | +| **Colour contrast** | Light dashboards rely on dark grey text on white; known gap: some decorative images use empty `alt` | + +### Gaps / follow-ups + +- Dark-mode tokens exist but are not wired as the default experience for main routes. +- Not all images have descriptive `alt` (logo and wallpapers are decorative). +- No documented WCAG conformance level or automated a11y test suite in repo. + +--- + +## Source reference + +| Topic | Primary files | +|-------|----------------| +| Theme tokens | `frontend/src/styles/theme.css` | +| Fonts | `frontend/src/styles/fonts.css`, `frontend/src/styles/index.css` | +| shadcn components | `frontend/src/landing/app/components/ui/` | +| Auth branding | `frontend/src/landing/imports/Login/Login.tsx`, `Register/Register.tsx` | +| Dashboard shell | `frontend/src/landing/imports/Group14/Group14.tsx` | +| Layout constants | `frontend/src/landing/app/lib/dashboardLayout.ts` | +| Profile assets | `frontend/src/landing/app/assets/profile/` | diff --git a/.github/docs/CICD.md b/.github/docs/CICD.md index f09b052c..e22f03f6 100644 --- a/.github/docs/CICD.md +++ b/.github/docs/CICD.md @@ -9,6 +9,7 @@ This directory contains automated workflows that run on every push and pull requ | Workflow | Trigger | Required | |----------|---------|----------| | `backend-tests.yml` | Push/PR to main/dev | Yes | +| `frontend-tests.yml` | Push/PR to main/dev | Yes | | `security.yml` | Push/PR + weekly | Yes | ## Workflows @@ -19,14 +20,27 @@ This directory contains automated workflows that run on every push and pull requ **What it does:** - **Code Quality** (Ruff linting, Black formatting) - **Unit Tests** (pytest with coverage) -- **Coverage Reports** (uploaded to Codecov) +- **Coverage Reports** (pytest --cov=app) - **Artifacts** (HTML coverage report saved for 30 days) **Required to pass before merge:** YES --- -### 2. `security.yml` +### 2. `frontend-tests.yml` +**Runs on:** Every push/PR to `main` or `dev` branches affecting `frontend/` + +**What it does:** +- **Code Quality** (ESLint, Prettier) +- **Unit Tests** (jest with coverage) +- **Coverage Reports** (vitest) +- **Artifacts** (HTML coverage report saved for 30 days) + +**Required to pass before merge:** YES + +--- + +### 3. `security.yml` **Runs on:** Every push/PR + weekly schedule **What it does:** @@ -58,10 +72,26 @@ This directory contains automated workflows that run on every push and pull requ - HTML reports can be downloaded ## Local Testing +_Before pushing, run locally_ + +### Frontend -Before pushing, run locally: +```sh +cd frontend + +# Install dev dependencies +npm install + +# run format and lint checks +npm run format:check +npm run lint + +# Run tests with coverage +npm run test:coverage -- --run +``` -```bash +### Backend +```sh cd backend # Install dev dependencies diff --git a/.github/docs/Dev-Quickstart.md b/.github/docs/Dev-Quickstart.md new file mode 100644 index 00000000..ca56fd9b --- /dev/null +++ b/.github/docs/Dev-Quickstart.md @@ -0,0 +1,146 @@ +# Dev Quickstart — Seed, Backend, Login + +Short runbook for getting a local dev environment with seeded test data and signing in as the demo user. + +For full infrastructure setup (Dev Container, database schema, troubleshooting), see [Setup.md](./Setup.md). + +--- + +## Prerequisites + +- Project opened in the **Dev Container** (recommended), or local Postgres with `DATABASE_URL` pointing at `localhost:5432` +- **`backend/.env`** — copy from `backend/.env.example` +- **`frontend/.env`** — copy from `frontend/.env.example` + +### Required `backend/.env` values + +```env +DATABASE_URL=postgresql+asyncpg://riot_user:riot_password@db:5432/riot_db +JWT_SECRET=change-me-to-a-long-random-string +SEED_DEV_PASSWORD=your-team-dev-password +RIOT_API_KEY=your_key_here +``` + +Inside the dev container, the database host must be **`db`**, not `localhost`. + +### Required `frontend/.env` values + +```env +VITE_API_URL=http://localhost:8000 +``` + +--- + +## Step 1 — Seed the database + +From inside the dev container: + +```bash +cd /workspaces/backend +python -m app.database.seed +``` + +Outside the container (with a local venv and Postgres on port 5432): + +```bash +cd backend +python -m app.database.seed +``` + +**Success:** output ends with `--- Seed complete ---` and a line like: + +```text +Dev login: testuser1@vantagepoint.dev (password from SEED_DEV_PASSWORD) +``` + +**Important:** + +- `SEED_DEV_PASSWORD` must be set in `backend/.env` before running seed. +- Seed **drops and recreates all tables**. Re-run it whenever you need a clean dev dataset or after schema changes. + +--- + +## Step 2 — Start the backend + +```bash +cd /workspaces/backend +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +**Verify:** open [http://localhost:8000](http://localhost:8000). Expected response: + +```json +{"message": "Vantage Point API running", "db_status": "Ready"} +``` + +--- + +## Step 3 — Start the frontend + +In a **second terminal**: + +```bash +cd /workspaces/frontend +npm run dev +``` + +Open the URL Vite prints (usually [http://localhost:5173](http://localhost:5173)). + +--- + +## Step 4 — Log in + +| Field | Value | +|--------|--------| +| **Email** | `testuser1@vantagepoint.dev` | +| **Password** | The value of `SEED_DEV_PASSWORD` in `backend/.env` | + +Use **testuser1** for the full demo dataset (matches, profile, achievements). +`testuser2@vantagepoint.dev` uses the same password but has a different, mostly empty game account. + +--- + +## What seed provides + +| Item | Details | +|------|---------| +| Dev users | `testuser1@vantagepoint.dev`, `testuser2@vantagepoint.dev` | +| Demo account (testuser1) | Riot ID **You#EUW**, PUUID `seed-viewer-puuid` | +| Match history | 8 seeded games with scoreboards | +| Profile extras | Achievements, featured-game banners, `profile_matches_sampled=20` | +| Champions | Static champion catalog populated | + +Newly registered accounts without seeded rows will have empty match history until they link a Riot ID and data is ingested. + +--- + +## Copy-paste checklist + +```text +1. backend/.env + frontend/.env (from .env.example) +2. cd backend && python -m app.database.seed +3. cd backend && uvicorn app.main:app --reload +4. cd frontend && npm run dev +5. Login: testuser1@vantagepoint.dev / +``` + +--- + +## Troubleshooting + +| Problem | What to do | +|---------|------------| +| Seed fails: `SEED_DEV_PASSWORD` | Add `SEED_DEV_PASSWORD=...` to `backend/.env`. | +| Seed / backend cannot connect to DB | Run inside the dev container; use host `db` in `DATABASE_URL`. | +| Login fails after re-seed | Password must match the **current** `SEED_DEV_PASSWORD`, not an old value. | +| Empty matches after login | Sign in as **testuser1**, not a new account without seeded data. | +| Frontend cannot reach API | Set `VITE_API_URL=http://localhost:8000` in `frontend/.env`. | +| Backend run outside container | Change `DATABASE_URL` host from `db` to `localhost`. | + +--- + +## Related docs + +- [Setup.md](./Setup.md) — Dev Container, database schema, verification +- [Backend-Development-Guide.md](./Backend-Development-Guide.md) — API and backend development +- [Frontend-Development-Guide.md](./Frontend-Development-Guide.md) — UI development diff --git a/.github/docs/FRONTEND_DEV.md b/.github/docs/Frontend-Development-Guide.md similarity index 100% rename from .github/docs/FRONTEND_DEV.md rename to .github/docs/Frontend-Development-Guide.md diff --git a/.github/docs/SRS_v2.pdf b/.github/docs/SRS_v2.pdf new file mode 100644 index 00000000..e72afa52 Binary files /dev/null and b/.github/docs/SRS_v2.pdf differ diff --git a/.github/docs/SCRUM_SETUP.md b/.github/docs/Scrum-Setup.md similarity index 100% rename from .github/docs/SCRUM_SETUP.md rename to .github/docs/Scrum-Setup.md diff --git a/.github/docs/SETUP.md b/.github/docs/Setup.md similarity index 57% rename from .github/docs/SETUP.md rename to .github/docs/Setup.md index 59d703ce..8a7d722f 100644 --- a/.github/docs/SETUP.md +++ b/.github/docs/Setup.md @@ -29,6 +29,27 @@ Have a `backend/.env` before running the backend. This file is gitignored so not ```env DATABASE_URL=postgresql+asyncpg://riot_user:riot_password@db:5432/riot_db RIOT_API_KEY=your_key_here +JWT_SECRET=change-me-to-a-long-random-string +SEED_DEV_PASSWORD=choose-a-dev-only-password +JWT_ACCESS_EXPIRE_MINUTES=30 +JWT_REFRESH_EXPIRE_DAYS=7 +``` + +For the frontend, copy `frontend/.env.example` to `frontend/.env` and set: + +```env +VITE_API_URL=http://localhost:8000 +``` + +### Dev Container environment variables (`.devcontainer/.env`) + +To avoid hardcoding database credentials in the Docker Compose file (required for SonarQube security compliance), create a `.env` file inside the `.devcontainer/` folder: + +```bash +# .devcontainer/.env +POSTGRES_USER=riot_user +POSTGRES_PASSWORD=riot_password +POSTGRES_DB=riot_db ``` `DATABASE_URL` is also injected by Docker Compose so the app starts correctly even without `.env` inside the container. The `.env` file is needed for running scripts like `test_db.py` directly. @@ -49,15 +70,19 @@ Note the host is `db`, not `localhost` — that's the service name from `docker- ### Schema and ER Diagram -| Table | Primary Key | Notes | -|---|---|---| -| `champions` | `champion_id` (int) | Matches Riot's own champion ID | -| `summoners` | `puuid` (str) | Riot's global player identifier, stable across name changes | -| `matches` | `match_id` (str) | Format: `EUW1_XXXXXXXXX` | -| `participants` | `internal_id` (int, auto) | Join table — links a summoner, match, and champion together | - -`participants` holds foreign keys to all three other tables, so all three must have a matching row before a participant can be inserted. - +| Table | Primary Key | Notes | +|----------------------|-----------------------|-------| +| `users` | `id` (UUID str) | Email/password auth; `display_name` for profile header | +| `game_accounts` | `puuid` (str) | Riot player ID; `profile_matches_sampled` drives "Last N matches" label | +| `user_game_accounts` | `id` (int, auto) | Join table allowing a user to link multiple game accounts | +| `achievement_definitions` | `id` (str) | Achievement catalog (label, description, source field) | +| `user_achievements` | `id` (int, auto) | Per-PUUID achievement counts for profile | +| `user_featured_games`| `id` (int, auto) | Featured-game banner slides per PUUID | +| `matches` | `match_id` (str) | Riot match ID; includes `game_creation`, `map_id`, `played_on`, `detail_json` (scoreboard) | +| `champions` | `champion_id` (int) | Matches Riot's own champion ID | +| `participants` | `internal_id` (int, auto) | Links a game account, match, and champion; stores per‑match stats | + +`participants` holds foreign keys to `matches`, `game_accounts`, and `champions`. All three must have a matching row before a participant can be inserted. A player's in‑game stats (kills, deaths, etc.) are stored directly in this table, while the user's real‑world identity is linked indirectly via `game_accounts` → `user_game_accounts` → `users`. ### Starting the backend (creates tables on first run) ```bash @@ -100,13 +125,49 @@ Then list tables: Expected output: ``` - Schema | Name | Type | Owner ---------+--------------+-------+----------- - public | champions | table | riot_user - public | matches | table | riot_user - public | participants | table | riot_user - public | summoners | table | riot_user + Schema | Name | Type | Owner +--------+--------------------+-------+----------- + public | champions | table | riot_user + public | game_accounts | table | riot_user + public | matches | table | riot_user + public | participants | table | riot_user + public | user_game_accounts | table | riot_user + public | users | table | riot_user ``` +## Seeding the Database with Champion Data + +The database starts empty. To populate the `champions` table with real Riot IDs and static stats from the dataset, run the seed script **manually**: + +```bash +cd backend +python3 -m venv .venv # first time only +.venv/bin/pip install -r requirements.txt # first time only +.venv/bin/python -m app.database.seed +``` + +### Auth API (email/password + JWT) + +After changing the `users` schema, reset the database (seed drops and recreates tables): + +```bash +cd /workspaces/backend +python -m app.database.seed +``` + +| Endpoint | Method | Auth | Purpose | +|----------|--------|------|---------| +| `/api/v1/auth/register` | POST | No | Create account; returns access + refresh tokens | +| `/api/v1/auth/login` | POST | No | Sign in | +| `/api/v1/auth/refresh` | POST | No | Refresh access token | +| `/api/v1/users/me` | GET | Bearer | Profile identity + linked Riot ID | +| `/api/v1/users/me/profile` | GET | Bearer | Profile aggregates (radar, champions, achievements) | +| `/api/v1/users/me/game-accounts` | POST | Bearer | Link Riot ID (`Name#TAG`) via Riot API | +| `/api/v1/matches` | GET | Bearer | Match history for linked account | +| `/api/v1/matches/{match_id}` | GET | Bearer | Full match detail (scoreboard) | + +Seed users: `testuser1@vantagepoint.dev` / `testuser2@vantagepoint.dev` with password from `SEED_DEV_PASSWORD` in `backend/.env`. See [Dev-Quickstart.md](./Dev-Quickstart.md) for the full seed → run → login flow. + +**Seeded dev data (viewer `You#EUW`, PUUID `seed-viewer-puuid`):** 8 matches, 7 achievements, 2 featured-game banners, `profile_matches_sampled=20`. Match list rows come from `participants`; each match’s scoreboard in `matches.detail_json` is built per `match_id` and aligned with the viewer’s list stats (champion, KDA, win/loss). Radar and recent champions are computed from `participants`; achievements and banner stats are read from `user_achievements` / `user_featured_games` (not from Match-v5 yet). Sign in as `testuser1` after seeding. Real Riot-linked accounts without seeded rows get empty achievements/banners until ingestion is added. ## Visualising the Schema diff --git a/.github/images/Fabio.jpg b/.github/images/Fabio.jpg new file mode 100644 index 00000000..f61d3210 Binary files /dev/null and b/.github/images/Fabio.jpg differ diff --git a/.github/images/Neo.jpg b/.github/images/Neo.jpg new file mode 100644 index 00000000..738104eb Binary files /dev/null and b/.github/images/Neo.jpg differ diff --git a/.github/images/Ophelia.jpeg b/.github/images/Ophelia.jpeg new file mode 100644 index 00000000..8b37f462 Binary files /dev/null and b/.github/images/Ophelia.jpeg differ diff --git a/.github/images/Shaun.jpeg b/.github/images/Shaun.jpeg new file mode 100644 index 00000000..bf31253b Binary files /dev/null and b/.github/images/Shaun.jpeg differ diff --git a/.github/images/Vele.png b/.github/images/Vele.png new file mode 100644 index 00000000..26b70570 Binary files /dev/null and b/.github/images/Vele.png differ diff --git a/.github/instructions/sonarqube_mcp.instructions.md b/.github/instructions/sonarqube_mcp.instructions.md new file mode 100644 index 00000000..5cbb5e64 --- /dev/null +++ b/.github/instructions/sonarqube_mcp.instructions.md @@ -0,0 +1,42 @@ +--- +applyTo: "**/*" +--- + +These are some guidelines when using the SonarQube MCP server. + +# Important Tool Guidelines + +## Basic usage +- **IMPORTANT**: After you finish generating or modifying any code files at the very end of the task, you MUST call the `analyze_file_list` tool (if it exists) to analyze the files you created or modified. +- **IMPORTANT**: When starting a new task, you MUST disable automatic analysis with the `toggle_automatic_analysis` tool if it exists. +- **IMPORTANT**: When you are done generating code at the very end of the task, you MUST re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists. + +## Project Keys +- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key +- Don't guess project keys - always look them up + +## Code Language Detection +- When analyzing code snippets, try to detect the programming language from the code syntax +- If unclear, ask the user or make an educated guess based on syntax + +## Branch and Pull Request Context +- Many operations support branch-specific analysis +- If user mentions working on a feature branch, include the branch parameter + +## Code Issues and Violations +- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates + +# Common Troubleshooting + +## Authentication Issues +- SonarQube requires USER tokens (not project tokens) +- When the error `SonarQube answered with Not authorized` occurs, verify the token type + +## Project Not Found +- Use `search_my_sonarqube_projects` to find available projects +- Verify project key spelling and format + +## Code Analysis Issues +- Ensure programming language is correctly specified +- Remind users that snippet analysis doesn't replace full project scans +- Provide full file content for better analysis results diff --git a/.github/workflows/backend_tests.yml b/.github/workflows/backend_tests.yml index d34d275f..18b2039f 100644 --- a/.github/workflows/backend_tests.yml +++ b/.github/workflows/backend_tests.yml @@ -25,20 +25,46 @@ jobs: run: | cd backend pip install -r requirements-dev.txt + pip install -r requirements.txt - name: Format check with Black + env: + JWT_SECRET: test-ci-secret + DATABASE_URL: postgresql+asyncpg://riot_user:riot_password@localhost:5432/riot_db run: cd backend && black --check app - name: Lint with Ruff + env: + JWT_SECRET: test-ci-secret + DATABASE_URL: postgresql+asyncpg://riot_user:riot_password@localhost:5432/riot_db run: cd backend && ruff check app - name: Type check with MyPy + env: + JWT_SECRET: test-ci-secret + DATABASE_URL: postgresql+asyncpg://riot_user:riot_password@localhost:5432/riot_db run: cd backend && mypy app test: name: Unit Tests and Coverage runs-on: ubuntu-latest needs: quality + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: riot_user + POSTGRES_PASSWORD: riot_password + POSTGRES_DB: riot_db + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v4 @@ -50,8 +76,13 @@ jobs: run: | cd backend pip install -r requirements-dev.txt + pip install -r requirements.txt - name: Run tests with coverage + env: + JWT_SECRET: test-ci-secret + TEST_USER_PASSWORD: test-ci-user-password + DATABASE_URL: postgresql+asyncpg://riot_user:riot_password@localhost:5432/riot_db run: cd backend && pytest --cov=app --cov-report=xml --cov-report=html --cov-report=term - name: Upload coverage artifact @@ -61,10 +92,37 @@ jobs: path: backend/htmlcov/ retention-days: 30 - # Limit reached - Renenable when codecov limit is lifted - # - name: Upload to Codecov - # uses: codecov/codecov-action@ab904c41d6ece82784817410c45d8b8c02684457 - # with: - # file: ./backend/coverage.xml - # flags: backend - # fail_ci_if_error: true + - name: Generate coverage badge + run: | + cd backend + COVERAGE=$(python -c "import xml.etree.ElementTree as ET; root = ET.parse('coverage.xml').getroot(); print(f'{float(root.attrib[\"line-rate\"])*100:.1f}')") + echo "COVERAGE=$COVERAGE" >> $GITHUB_ENV + + if (( $(echo "$COVERAGE >= 80" | bc -l) )); then + COLOR="green" + elif (( $(echo "$COVERAGE >= 70" | bc -l) )); then + COLOR="yellow" + else + COLOR="red" + fi + + curl "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" -o coverage-badge.svg + + - name: Upload coverage badge + uses: actions/upload-artifact@v4 + with: + name: coverage-badge + path: backend/coverage-badge.svg + retention-days: 30 + + - name: Commit and push coverage badge + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' + run: | + cd backend + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add coverage-badge.svg + git commit -m "chore: update backend coverage badge" || true + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/frontend_tests.yml b/.github/workflows/frontend_tests.yml new file mode 100644 index 00000000..a0ddd0f8 --- /dev/null +++ b/.github/workflows/frontend_tests.yml @@ -0,0 +1,167 @@ +name: Frontend Tests & Quality + +on: + push: + branches: [main, dev] + paths: + - 'frontend/**' + - '.github/workflows/frontend_tests.yml' + pull_request: + branches: [main, dev] + paths: + - 'frontend/**' + - '.github/workflows/frontend_tests.yml' + +env: + NODE_VERSION: '20.x' + COVERAGE_THRESHOLD: 70 + +jobs: + # ============================================================================ + # SECURITY: DEPENDENCY SCANNING + # ============================================================================ + security: + name: Security & Dependency Scanning + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install dependencies + working-directory: frontend + run: npm ci --legacy-peer-deps + + - name: Check npm packages for vulnerabilities + working-directory: frontend + run: npm audit --audit-level=high + + # ============================================================================ + # LEVEL 1: CODE QUALITY GATE (Lint + Format Check) + # ============================================================================ + quality: + name: Code Quality & Linting + runs-on: ubuntu-latest + needs: security + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install dependencies + working-directory: frontend + run: npm ci --legacy-peer-deps + + - name: Format check with Prettier + working-directory: frontend + run: npm run format:check + + - name: Lint with ESLint + working-directory: frontend + run: npm run lint + + # ============================================================================ + # LEVEL 2: UNIT TESTS & COVERAGE + # ============================================================================ + test: + name: Unit Tests and Coverage + runs-on: ubuntu-latest + needs: quality + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install dependencies + working-directory: frontend + run: npm ci --legacy-peer-deps + + - name: Run tests with coverage + working-directory: frontend + run: npm run test:coverage -- --run + + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: frontend/coverage/ + retention-days: 30 + + - name: Generate coverage badge + working-directory: frontend + run: | + COVERAGE=$(cat coverage/coverage-final.json | python -c "import sys, json; data = json.load(sys.stdin); lines = [v['lines']['pct'] for v in data.values() if 'lines' in v]; avg = sum(lines)/len(lines) if lines else 0; print(f'{avg:.1f}')") + echo "COVERAGE=$COVERAGE" >> $GITHUB_ENV + + if (( $(echo "$COVERAGE >= 80" | bc -l) )); then + COLOR="green" + elif (( $(echo "$COVERAGE >= 70" | bc -l) )); then + COLOR="yellow" + else + COLOR="red" + fi + + curl "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" -o coverage-badge.svg + + - name: Upload coverage badge + uses: actions/upload-artifact@v4 + with: + name: coverage-badge + path: frontend/coverage-badge.svg + retention-days: 30 + + # ============================================================================ + # LEVEL 3: BUILD VERIFICATION + # ============================================================================ + build: + name: Build Verification + runs-on: ubuntu-latest + needs: quality + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' + + - name: Install dependencies + working-directory: frontend + run: npm ci --legacy-peer-deps + + - name: Build production bundle + working-directory: frontend + run: npm run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: frontend-build-artifacts + path: frontend/dist/ + retention-days: 7 + if-no-files-found: ignore + + - name: Commit and push coverage badge + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' + working-directory: frontend + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add coverage-badge.svg + git commit -m "chore: update frontend coverage badge" || true + git push + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9ddaba31..2f1c781e 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -24,7 +24,7 @@ jobs: cd backend pip install -r requirements.txt pip install pip-audit - pip-audit --ignore CVE-2026-3219 --ignore CVE-2026-6357 --ignore CVE-2025-71176 + pip-audit --ignore CVE-2025-71176 --ignore CVE-2026-30922 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/wiki_sync.yml.skip b/.github/workflows/wiki_sync.yml.skip new file mode 100644 index 00000000..30d7e5b9 --- /dev/null +++ b/.github/workflows/wiki_sync.yml.skip @@ -0,0 +1,24 @@ +name: Sync Docs Folder to GitHub Wiki + +on: + push: + branches: + - main + paths: + - '.github/docs/**' + +permissions: + contents: write + +jobs: + sync-wiki: + runs-on: ubuntu-latest + steps: + - name: Checkout Codebase + uses: actions/checkout@v4 + + - name: Sync to GitHub Wiki + uses: Andrew-Chen-Wang/github-wiki-action@v4 + with: + path: .github/docs + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 31b5de3d..dbd4dc55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,48 @@ # Backend __pycache__/ *.py[cod] +*.pyc +*.pyo +*.pyd +.Python +*.so +*.dll +*.dylib +pip-log.txt +pip-delete-this-directory.txt +.pytest/ +*.egg *.egg-info/ .pytest_cache/ .ruff_cache/ .mypy_cache/ .coverage .coverage.* +*.coverage.* backend/.coverage +coverage.xml +*.lcov +.nyc_output/ htmlcov/ backend/app/tests/fixtures/ backend/app/tests/integration/ +dist/ +build/ +*.whl +pip-wheel-metadata/ +share/python-wheels/ +backend/registrations.txt + +*.py[cod] +*$py.class # Virtual Environment venv/ env/ .venv +ENV/ +venv.bak/ +env.bak/ # Environment Variables .env @@ -30,6 +57,7 @@ env/ node_modules/ frontend/dist/ frontend/build/ +frontend/coverage # OS .DS_Store @@ -42,5 +70,8 @@ Thumbs.db build/ dist/ +# User uploads +backend/uploads/ + # Other .postman diff --git a/.vite/deps/_metadata.json b/.vite/deps/_metadata.json deleted file mode 100644 index b22dfb15..00000000 --- a/.vite/deps/_metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "hash": "bb0ea7bd", - "configHash": "52167615", - "lockfileHash": "e3b0c442", - "browserHash": "99c09629", - "optimized": {}, - "chunks": {} -} \ No newline at end of file diff --git a/.vite/deps/package.json b/.vite/deps/package.json deleted file mode 100644 index 3dbc1ca5..00000000 --- a/.vite/deps/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/API-Call-Documentation.docx b/API-Call-Documentation.docx deleted file mode 100644 index 6db7eef6..00000000 Binary files a/API-Call-Documentation.docx and /dev/null differ diff --git a/Backend Tech Summary as IS.docx b/Backend Tech Summary as IS.docx deleted file mode 100644 index 75d710f8..00000000 Binary files a/Backend Tech Summary as IS.docx and /dev/null differ diff --git a/README.md b/README.md index be7d69c3..453876f9 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ Transform your gameplay through advanced positioning analysis. Move beyond K/D ratios and discover the data-driven insights that separate top-tier players from the rest. +[![Backend Tests](https://github.com/COS301-SE-2026/Vantage-Point/actions/workflows/backend_tests.yml/badge.svg)](https://github.com/COS301-SE-2026/Vantage-Point/actions/workflows/backend_tests.yml) +[![Backend Coverage](backend/coverage-badge.svg)](backend/htmlcov/index.html) +[![Frontend Tests](https://github.com/COS301-SE-2026/Vantage-Point/actions/workflows/frontend_tests.yml/badge.svg)](https://github.com/COS301-SE-2026/Vantage-Point/actions/workflows/frontend_tests.yml) +[![Frontend Coverage](frontend/coverage-badge.svg)](frontend/coverage/index.html) +[![Security](https://github.com/COS301-SE-2026/Vantage-Point/actions/workflows/security.yml/badge.svg)](https://github.com/COS301-SE-2026/Vantage-Point/actions/workflows/security.yml) + --- ## About @@ -15,23 +21,23 @@ Vantage Point is a spatial intelligence platform designed for competitive gamers ## The F.R.O.S.N Team *Pending team picture* -| Name | Picture | Role | -|------|---------|------| -| Fabio Berrino | *Pending picture* | Scrum Master and DevOps Engineer | -| Shaun Marx | *Pending picture* | Backend Developer | -| Vele Ndamulelo | *Pending picture* | Frontend Developer | -| Neo Machaba | *Pending picture* | Database Manager | -| Ophelia Greyling | *Pending picture* | AI/ML Developer | +| Name | Picture | Role | Description | LinkedIn | +|------|---------|------|-------------|----------| +| Fabio Berrino | ![Fabio](.github/images/Fabio.jpg) | Scrum Master and DevSecOps Engineer | I am a BSc Information and Knowledge Systems Student Specialising in Software Development. I am Interestred in everything related to technology ranging from software development to DevSecOps and AI/ML. | [LinkedIn](https://www.linkedin.com/in/fabio-b-15357777fa/) | +| Ophelia Greyling | ![Ophelia](.github/images/Ophelia.jpeg) | Data Analyst and AI/ML Engineer | I am a Computer Science student with a deep interest in data science and its various applications, as well as the mechanisms between computer networks. I have plans to start working in a machine learning related field in the second semester. | [LinkedIn](https://www.linkedin.com/in/zanri-greyling-031636271/) | +| Vele Ndamulelo | ![Vele](.github/images/Vele.png) | Designer and Frontend Developer | I am a BSC Computer Science student focused on building scalable software systems that reduce complexity and improve efficiency. | [LinkedIn](https://www.linkedin.com/in/vele-ndamulelo-3a3085372/) | +| Neo Machaba | ![Neo](.github/images/Neo.jpg) | Database Manager | I am a BSC Computer Science student, I am interested in Data sciene, engineering and analyst with a goal in improving appplication and workflow efficiency with applying networking in order to reduce system bottlenecking. | [LinkedIn](https://www.linkedin.com/in/neo-machaba) | +| Shaun Marx | ![Shaun](.github/images/Shaun.jpeg) | API and Backend Developer | I am an IKS student with an interest in building software systems from scratch and applying them in different environments. I am especially interested in software engineering, and backend development. | [LinkedIn](https://www.linkedin.com/in/shaun-marx-07bbb63b6/) | ## Team Roles | Role | Responsibility | |------|-----------------| -| **Scrum Master and DevOps Engineer** | Process facilitation, blocker removal, team velocity tracking, CI/CD pipeline management, AWS Integration | -| **Backend Developer** | API design, ORM setup, ML model integration, FastAPI development, Match-v5 API Integration | -| **Frontend Developer** | UI/UX design, component architecture, performance optimization, React + D3.js implementation | +| **Scrum Master and DevSecOps Engineer** | Process facilitation, blocker removal, team velocity tracking, CI/CD pipeline management, AWS Integration and Vulnerability scanning | +| **API and Backend Developer** | API design, ORM setup, ML model integration, FastAPI development and Match-v5 API Integration | +| **Designer and Frontend Developer** | UI/UX design, component architecture, performance optimization, React + D3.js implementation | | **Database Manager** | Database schema design, query optimization, data integrity, PostgreSQL management | -| **AI/ML Developer** | Machine learning model development, data science pipeline, model training and optimization | +| **Data Analysis and AI/ML Engineer** | Machine learning model development, data science pipeline, model training and optimization | --- @@ -80,33 +86,50 @@ Vantage Point is a spatial intelligence platform designed for competitive gamers ## Project Structure ``` Vantage-Point/ -├── backend/ # FastAPI server +├── .devcontainer/ # Local containerized development environment +│ ├── docker-compose.yml # Multi-container orchestration (DB, App, Client) +│ ├── devcontainer.json # VS Code container environment specification tool settings +│ ├── post-create.sh # Automated environment setup script +│ └── start-services.sh # Service initialization script +│ +├── .github/ # GitHub workflows, actions, and workspace configurations +│ ├── docs/ # Internal design, API, and scrum methodology logs +│ └── workflows/ # CI/CD automated test & security pipelines +│ +├── backend/ # FastAPI REST API & machine learning engine │ ├── app/ -│ │ ├── api/ # API routes (v1) -│ │ ├── services/ # Business logic -│ │ ├── models/ # SQLModel schemas -│ │ ├── tests/ # Unit & integration tests -│ │ ├── utils/ # Logging, helpers -│ │ └── main.py # App entry point -│ ├── requirements-dev.txt -│ └── README.md # Backend guide +│ │ ├── api/ # API router configurations & routing middleware +│ │ ├── database/ # Database layer, schema + models, seeding scripts and session transaction engine +│ │ ├── pred_engine/ # Match analytics predictive ML pipeline +│ │ │ └── knn_model.py # K-Nearest Neighbors core model logic +│ │ ├── schemas/ # Used in Services +│ │ ├── services/ # Decoupled business logic & provider layer +│ │ ├── tests/ # Unit & Integration Tests; Automated backend testing logic (Pytest) +│ │ ├── utils/ # Helper functions and rate-limiting scripts +│ │ ├── config.py # App environment configuration & secrets manager +│ │ └── main.py # Application server root entry point +│ ├── mypy.ini # Static type linting rules +│ ├── pytest.ini # Pytest execution configurations +│ ├── requirements.txt # Runtime server dependencies +│ └── requirements-dev.txt # Local test/lint utilities │ -├── frontend/ # React + Vite + Tailwind +├── frontend/ # Single-Page Application (React + Vite + Tailwind) +│ ├── public/ # Global static assets (SVG favicons/icons) │ ├── src/ -│ │ ├── components/ # React components -│ │ ├── pages/ # Page components -│ │ ├── assets/ # Images, fonts (bundled) -│ │ ├── __tests__/ # Vitest tests -│ │ ├── utils/ # Helpers, services -│ │ └── App.jsx # Entry component -│ ├── public/ # Static files (favicon, manifest) -│ ├── package.json -│ └── README.md # Frontend guide -│ -├── .github/ -│ └── workflows/ # GitHub Actions CI/CD +│ │ ├── pages/ # Client interface layouts (Login, Register, Dashboard) +│ │ ├── services/ # Axios/Fetch client endpoints (authService, API configurations) +│ │ ├── __tests__/ # Client-side Vitest test suites +│ │ ├── App.jsx # Application shell and component router +│ │ ├── index.css # Main stylesheet & Tailwind imports +│ │ └── main.jsx # Client virtual DOM registration entry point +│ ├── eslint.config.js # JS code style linting rules +│ ├── tailwind.config.js # Utility-first CSS theme extensions +│ ├── vite.config.js # Vite bundling engine customization +│ └── vitest.config.ts # Client testing runtime configurations │ -└── README.md # Main project guide +├── .gitignore # Global Git version control exclusions +├── .gitattributes # Global Git configuration file used to define standardized line endings +└── README.md # Primary project overview and setup documentation ``` ## Branching Strategy @@ -138,8 +161,13 @@ To ensure a stable and collaborative development workflow, the following strateg ## Documentation -- **[Setup Guide](.github/docs/SETUP.md)** - Initial project setup and dependencies -- **[Backend Development Guide](.github/docs/BACKEND_DEV.md)** - Backend setup, testing, API development, code quality -- **[Frontend Development Guide](.github/docs/FRONTEND_DEV.md)** - Frontend setup, components, styling, testing +- **[Project Board](https://github.com/orgs/COS301-SE-2026/projects/32/views/6)** - Sprint planning and task tracking +- **[Setup Guide](.github/docs/Setup.md)** - Initial project setup and dependencies +- **[Functional Requirements (SRS)](.github/docs/SRS_v2.pdf)** - Functional, architectural and technology requirements +- **[Dev Quickstart](.github/docs/Dev-Quickstart.md)** - Seed database, run backend/frontend, sign in as test user +- **[Backend Development Guide](.github/docs/Backend-Development-Guide.md)** - Backend setup, testing, API development, code quality +- **[Frontend Development Guide](.github/docs/Frontend-Development-Guide.md)** - Frontend setup, components, styling, testing - **[CI/CD Documentation](.github/docs/CICD.md)** - GitHub Actions workflows, automated testing, deployment pipeline -- **[SCRUM & Sprint Planning](.github/docs/SCRUM_SETUP.md)** - Sprint roadmap, ceremonies, backlog, velocity tracking +- **[SCRUM & Sprint Planning](.github/docs/Scrum-Setup.md)** - Sprint roadmap, ceremonies, backlog, velocity tracking +- **[Brand Style Guide](.github/docs/Brand-Style.md)** - Brand style guide for consistent UI/UX across the application +- **[Wireframes](https://www.figma.com/design/cUssojtAVvCokYU7k9yAmW/vantage-point?m=auto&t=oenm7gYsMCu5Tsix-6)** - Wireframes for the application UI diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 00000000..1f86f67f --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +DATABASE_URL=postgresql+asyncpg://riot_user:riot_password@db:5432/riot_db +# Required for linking Riot IDs — create at https://developer.riotgames.com/ +RIOT_API_KEY=your_riot_api_key_here +JWT_SECRET=change-me-to-a-long-random-string +SEED_DEV_PASSWORD=choose-a-dev-only-password +TEST_USER_PASSWORD=choose-a-test-only-password +JWT_ACCESS_EXPIRE_MINUTES=30 +JWT_REFRESH_EXPIRE_DAYS=7 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 00000000..40245f42 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,115 @@ +from jose import jwt, JWTError +import httpx +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from app.config import get_settings +from typing import Any, cast + +settings = get_settings() +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/token") + +# Cache keys to avoid hitting AWS on every single request +jwks_cache: dict[str, Any] | None = None + + +async def get_jwks() -> dict[str, Any]: + global jwks_cache + + if jwks_cache is not None: + return jwks_cache + + issuer = ( + f"https://cognito-idp.{settings.aws_region}.amazonaws.com/" + f"{settings.cognito_user_pool_id}" + ) + + jwks_url = f"{issuer}/.well-known/jwks.json" + + async with httpx.AsyncClient() as client: + response = await client.get(jwks_url) + response.raise_for_status() + + jwks: dict[str, Any] = response.json() + jwks_cache = jwks + + return jwks + + +def get_public_key(token: str, jwks: dict[str, Any]) -> dict[str, Any]: + try: + header = jwt.get_unverified_header(token) + except JWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token header", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + token_kid = header.get("kid") + + if not token_kid: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token missing key ID", + headers={"WWW-Authenticate": "Bearer"}, + ) + + raw_keys = jwks.get("keys", []) + + if not isinstance(raw_keys, list): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid JWKS format", + headers={"WWW-Authenticate": "Bearer"}, + ) + + keys = cast(list[dict[str, Any]], raw_keys) + + for key in keys: + if key.get("kid") == token_kid: + return key + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Matching public key not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user(token: str = Depends(oauth2_scheme)) -> str: + global jwks_cache + issuer = f"https://cognito-idp.{settings.aws_region}.amazonaws.com/{settings.cognito_user_pool_id}" + + try: + jwks = await get_jwks() + public_key = get_public_key(token, jwks) + + # 2. Decode and verify the token + payload = jwt.decode( + token, + public_key, + algorithms=["RS256"], + audience=settings.cognito_client_id, + issuer=issuer, + ) + + user_id = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=401, + detail="Token missing subject", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return str(user_id) # Return the Cognito User ID + except JWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials.", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + except httpx.HTTPError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Could not fetch Cognito public keys", + ) from exc diff --git a/backend/app/api/middleware.py b/backend/app/api/middleware.py new file mode 100644 index 00000000..b1b8707e --- /dev/null +++ b/backend/app/api/middleware.py @@ -0,0 +1,15 @@ +import time +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + + +class ProcessTimeMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + start_time = time.perf_counter() + response = await call_next(request) + process_time = time.perf_counter() - start_time + # Add a custom header to see how fast your LoL math is running + response.headers["X-Process-Time"] = str(process_time) + return response diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py new file mode 100644 index 00000000..ce4820a0 --- /dev/null +++ b/backend/app/api/routes.py @@ -0,0 +1,454 @@ +from urllib.parse import parse_qs + +from fastapi import APIRouter, HTTPException, Depends, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from app.api.auth import get_current_user +from app.services import auth_service +from app.schemas.auth_schemas import ( + UserRegister, + UserLogin, + UserConfirm, +) +from app.schemas.profile_schemas import ( + MatchSummary, + MessageResponse, + ProfileResponse, + RiotKeyUpdateResponse, + LiveAdvancedMetrics, + ProfileCreateRequest, + ProfileUpdateRequest, +) +from app.schemas.generic_schemas import ErrorResponse +from typing import Annotated, Any +from pydantic import BaseModel, Field +from typing import List +from sqlalchemy.ext.asyncio import AsyncSession +from app.database.session import get_session +from app.schemas.riot_schemas import SimplifiedMatchResponse +from app.services.profile_services import ProfileService +from app.services.analytics import LiveAnalyticsService +from app.services.riot_service import riot_service, filter_match_for_players + +oauth2_scheme = HTTPBearer() + +router = APIRouter() + + +# +@router.post( + "/auth/register", + tags=["Authentication"], + summary="Register a new user", + description="Creates a new Cognito user account with username, email, and password.", + response_model=MessageResponse, + responses={ + 400: {"model": ErrorResponse, "description": "Registration failed"}, + }, +) +async def register(user: UserRegister): + result = await auth_service.register_user(user.username, user.password, user.email) + if "error" in result: + raise HTTPException(status_code=400, detail=result["error"]) + return {"message": "User registered successfully."} + + +@router.post( + "/auth/login", + tags=["Authentication"], + summary="Log in a user", + description="Authenticates a user with Cognito and returns the token payload from AWS.", + responses={ + 401: {"model": ErrorResponse, "description": "Invalid username or password"}, + }, +) +async def login(user: UserLogin) -> dict[str, Any]: + result = await auth_service.login_user(user.username, user.password) + if "error" in result: + raise HTTPException(status_code=401, detail="Invalid username or password") + return dict(result) + + +@router.post( + "/auth/confirm", + tags=["Authentication"], + include_in_schema=False, + summary="Confirm a registered user", + description="Confirms a Cognito signup using the verification code sent to the user.", + response_model=dict[str, str], + responses={ + 401: {"model": ErrorResponse, "description": "Confirmation failed"}, + }, +) +async def confirm(data: UserConfirm): + result = await auth_service.confirm_user(data.username, data.confirmation_code) + if "error" in result: + raise HTTPException(status_code=401, detail=result["error"]) + return result + + +@router.post( + "/auth/logout", + tags=["Authentication"], + summary="Log out the current user", + description="Invalidates the authenticated user's Cognito access token globally.", + response_model=MessageResponse, + responses={ + 400: {"model": ErrorResponse, "description": "Logout failed"}, + 403: {"model": ErrorResponse, "description": "Missing or invalid bearer token"}, + }, +) +async def logout( + token_data: Annotated[HTTPAuthorizationCredentials, Depends(oauth2_scheme)], +): + # Extracts the raw string credentials from the FastAPI HTTPBearer object + # needed for Cognito's global_sign_out + # jwt when logout request so use JWT get what user to infer which user logouts + raw_token = token_data.credentials + result = await auth_service.logout_user(raw_token) + if "error" in result: + raise HTTPException(status_code=400, detail=result["error"]) + return {"message": "Successfully logged out from all devices."} + + +@router.get( + "/profile", + tags=["Profile"], + summary="Get current user profile", + description="Retrieves the authenticated user's profile and mock gameplay summary.", + responses={ + 401: {"model": ErrorResponse, "description": "Invalid or expired token"}, + }, +) +async def get_profile( + current_user: Annotated[str, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> ProfileResponse: + """ + Retrieves the authenticated user's profile. + """ + profile = await ProfileService.get_or_create_profile(session, current_user) + total_matches, summary = await ProfileService.build_player_summary( + session, current_user + ) + + return ProfileResponse( + uuid=profile.user_id, + username=profile.username, + total_matches=total_matches, + player_summary=summary, + ) + + +@router.delete( + "/profile", + tags=["Profile"], + summary="Schedule account deletion", + description="Marks the authenticated account for deletion 30 days from now.", + response_model=MessageResponse, + responses={ + 401: {"model": ErrorResponse, "description": "Invalid or expired token"}, + }, +) +async def delete_account( + current_user: Annotated[str, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + deletion_date = await ProfileService.schedule_account_deletion( + session, current_user + ) + + print(f"--- Notification email sent to user {current_user} ---") + print("Subject: Account marked for deletion") + print(f"Your account will be removed on {deletion_date.strftime('%Y-%m-%d')}.") + + return { + "message": "Account marked for deletion. You have 30 days to undo this action." + } + + +@router.post( + "/profile/undo-delete", + tags=["Profile"], + summary="Undo scheduled account deletion", + description="Cancels a pending account deletion for the authenticated user.", + response_model=MessageResponse, + responses={ + 400: { + "model": ErrorResponse, + "description": "Account is not marked for deletion", + }, + 401: {"model": ErrorResponse, "description": "Invalid or expired token"}, + }, +) +async def undo_delete( + current_user: Annotated[str, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + if await ProfileService.undo_account_deletion(session, current_user): + return {"message": "Account deletion cancelled successfully."} + raise HTTPException( + status_code=400, + detail={"error_code": 4002, "message": "Account is not marked for deletion."}, + ) + + +@router.get( + "/matches", + tags=["Matches"], + summary="List recent matches", + description="Returns a mock list of recent matches for the authenticated user.", + response_model=List[MatchSummary], + responses={ + 401: {"model": ErrorResponse, "description": "Invalid or expired token"}, + }, +) +async def get_matches(current_user: Annotated[str, Depends(get_current_user)]): + # Mock list of matches + return [ + { + "match_id": "NA1_49201", + "map": "Summoner's Rift", + "game_mode": "Ranked Solo", + "duration": "32m 10s", + "status": "Victory", + "kda": "10/3/15", + "champion": "Thresh", + }, + { + "match_id": "NA1_49188", + "map": "Howling Abyss", + "game_mode": "ARAM", + "duration": "18m 45s", + "status": "Defeat", + "kda": "5/10/12", + "champion": "Lux", + }, + ] + + +class UpdateAPIKeyRequest(BaseModel): + riot_api_key: str = Field(..., description="Riot Games developer API key") + + +@router.put( + "/profile/riot-key", + tags=["Profile"], + summary="Update Riot API key", + description="Mock endpoint that validates updating the Riot API key for the current session.", + response_model=RiotKeyUpdateResponse, + responses={ + 401: {"model": ErrorResponse, "description": "Invalid or expired token"}, + 500: {"model": ErrorResponse, "description": "Failed to update API key"}, + }, +) +async def update_riot_api_key( + request: UpdateAPIKeyRequest, + current_user: Annotated[str, Depends(get_current_user)], +): + """ + MOCK: Updates the Riot API key for the current authenticated session. + """ + # Simulate updating the RiotService internal state + # In a real scenario, this would call riot_service.set_api_key(request.riot_api_key) + mock_success = True + + if not mock_success: + raise HTTPException(status_code=500, detail="Failed to update API key") + + # Log the action to your terminal for verification + print("--- [MOCK] API Key Updated ---") + print(f"User: {current_user}") + print(f"New Key: {request.riot_api_key[:10]}...") # Masked for safety + + return { + "message": "Riot API Key updated successfully for this session.", + "user": current_user, + "status": "mock_verified", + } + + +# ===================================================== +# Riot routes +# ====================================================== + + +@router.get( + "/riot/matches/{puuid}", + tags=["Riot"], + summary="Get Riot match IDs", + description="Fetches recent Riot match IDs for a player PUUID.", + response_model=List[str], + responses={ + 404: {"model": ErrorResponse, "description": "Player matches were not found"}, + }, +) +# @public #custom decorator used to bypass cognito for testing +async def get_player_matches( + server_region: str, puuid: str, count: int = 5 +) -> list[str]: + "GET a list of match IDs by player PUUID." + match_ids: list[str] = await riot_service.get_match_ids( + server_region=server_region, puuid=puuid, count=count + ) + + return match_ids + + +@router.get( + "/riot/matches/{match_id}/filtered", + tags=["Riot"], + summary="Get filtered Riot match", + description=( + "Fetches a full Riot match and returns a lightweight summary for one player " + "plus their teammates." + ), + response_model=SimplifiedMatchResponse, + responses={ + 404: {"model": ErrorResponse, "description": "Match or player was not found"}, + }, +) +async def get_filtered_match(match_id: str, puuid: str): + """ + Fetches a full match from Riot's API and shrinks the payload + down to a lightweight summary for a single player. + """ + + try: + raw_match_data = await riot_service.get_match_detail(match_id) + except Exception as e: + raise HTTPException(status_code=404, detail=f"Failed to fetch match: {str(e)}") + + full_match = raw_match_data + + simplified_match = filter_match_for_players( + full_match=full_match, target_puuid=puuid + ) + + if not simplified_match: + raise HTTPException( + status_code=404, + detail=f"Player with PUUID {puuid} was not found in match {match_id}", + ) + + return simplified_match + + +@router.get("/{server_region}/{puuid}/live-metrics", tags=["Live Metrics"]) +async def get_live_player_metrics( + server_region: str, puuid: str, count: int +) -> LiveAdvancedMetrics: + """ + Asynchronously reaches out to Riot's server architecture to evaluate a player's last N matches. + Computes precise performance indexes including KDA, Vision, GPM, DPM, CS/Min, and KP% on the fly. + """ + return await LiveAnalyticsService.get_live_metrics_from_api( + server_region=server_region, puuid=puuid, count=count + ) + + +@router.post( + "/token", + include_in_schema=False, + responses={ + 400: {"description": "Username and password are required"}, + 401: {"description": "Invalid username or password"}, + }, +) +async def swagger_login(request: Request) -> dict[str, str]: + form_data = parse_qs((await request.body()).decode()) + username = form_data.get("username", [""])[0] + password = form_data.get("password", [""])[0] + + if not username or not password: + raise HTTPException( + status_code=400, + detail="Username and password are required", + ) + + result = await auth_service.login_user( + username, + password, + ) + + if "error" in result: + raise HTTPException( + status_code=401, + detail="Invalid username or password", + ) + + return { + "access_token": result["IdToken"], + "token_type": "bearer", + } + + +@router.post( + "/profile", + tags=["Profile"], + summary="Create current user profile", + include_in_schema=False, + description="Creates a profile for the authenticated user.", + responses={ + 401: {"model": ErrorResponse, "description": "Invalid or expired token"}, + 409: {"model": ErrorResponse, "description": "Profile already exists"}, + }, +) +async def create_profile( + request: ProfileCreateRequest, + current_user: Annotated[str, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> ProfileResponse: + profile = await ProfileService.create_profile( + session=session, + user_id=current_user, + request=request, + ) + + total_matches, summary = await ProfileService.build_player_summary( + session, + current_user, + ) + + return ProfileResponse( + uuid=profile.user_id, + username=profile.username, + total_matches=total_matches, + player_summary=summary, + ) + + +@router.put( + "/profile", + tags=["Profile"], + summary="Update current user profile", + description="Updates the authenticated user's profile.", + responses={ + 401: {"model": ErrorResponse, "description": "Invalid or expired token"}, + 404: { + "model": ErrorResponse, + "description": "Profile or Riot account not found", + }, + }, +) +async def update_profile( + request: ProfileUpdateRequest, + current_user: Annotated[str, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +) -> ProfileResponse: + profile = await ProfileService.update_profile( + session=session, + user_id=current_user, + request=request, + ) + + total_matches, summary = await ProfileService.build_player_summary( + session, + current_user, + ) + + return ProfileResponse( + uuid=profile.user_id, + username=profile.username, + total_matches=total_matches, + player_summary=summary, + ) diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/auth/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/auth/deps.py b/backend/app/auth/deps.py new file mode 100644 index 00000000..8d612d7e --- /dev/null +++ b/backend/app/auth/deps.py @@ -0,0 +1,39 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from app.auth.jwt import verify_access_token +from app.database.models import Users +from app.database.session import get_session + +security = HTTPBearer(auto_error=False) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(security), + session: AsyncSession = Depends(get_session), +) -> Users: + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id = verify_access_token(credentials.credentials) + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await session.execute(select(Users).where(Users.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + return user diff --git a/backend/app/auth/jwt.py b/backend/app/auth/jwt.py new file mode 100644 index 00000000..6ff63de6 --- /dev/null +++ b/backend/app/auth/jwt.py @@ -0,0 +1,67 @@ +import os +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import JWTError, jwt + +JWT_SECRET = os.getenv("JWT_SECRET", "") +if not JWT_SECRET: + raise RuntimeError( + "JWT_SECRET environment variable must be set before starting the API" + ) +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_EXPIRE_MINUTES", "30")) +REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_EXPIRE_DAYS", "7")) + + +def _create_token( + subject: str, + token_type: str, + expires_delta: timedelta, +) -> str: + expire = datetime.now(timezone.utc) + expires_delta + payload: dict[str, Any] = { + "sub": subject, + "type": token_type, + "exp": expire, + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def create_access_token(user_id: str) -> str: + return _create_token( + user_id, + "access", + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), + ) + + +def create_refresh_token(user_id: str) -> str: + return _create_token( + user_id, + "refresh", + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), + ) + + +def decode_token(token: str) -> dict[str, Any]: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + + +def _verify_token(token: str, expected_type: str) -> str | None: + try: + payload = decode_token(token) + if payload.get("type") != expected_type: + return None + sub = payload.get("sub") + return str(sub) if sub else None + except JWTError: + return None + + +def verify_access_token(token: str) -> str | None: + return _verify_token(token, "access") + + +def verify_refresh_token(token: str) -> str | None: + return _verify_token(token, "refresh") diff --git a/backend/app/auth/passwords.py b/backend/app/auth/passwords.py new file mode 100644 index 00000000..ba04abb6 --- /dev/null +++ b/backend/app/auth/passwords.py @@ -0,0 +1,14 @@ +import bcrypt + + +def hash_password(password: str) -> str: + hashed: bytes = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + return hashed.decode("utf-8") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + result: bool = bcrypt.checkpw( + plain_password.encode("utf-8"), + hashed_password.encode("utf-8"), + ) + return result diff --git a/backend/app/config.py b/backend/app/config.py index e69de29b..e480fe53 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -0,0 +1,64 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict # type: ignore[import-not-found] +from pydantic import field_validator +from functools import lru_cache +from typing import Any, List, Optional + + +class Settings(BaseSettings): + """Application settings loaded from .env file""" + + # ============ Riot API Configuration ============ + riot_api_key: str = "" + riot_region: str = "americas" + riot_platform: str = "na1" + + # ============ AWS Cognito Configuration ============ + aws_region: str = "eu-west-1" + cognito_user_pool_id: str = "" + cognito_client_id: str = "" + cognito_client_secret: str = "" + + # ============ Server Configuration ============ + debug: bool = True + host: str = "0.0.0.0" + port: int = 8000 + # ============ Rate Limiting ============ + rate_limit_requests: int = 20 + rate_limit_seconds: int = 1 + rate_limit_per_2min: int = 100 + + # ============ Cache Configuration ============ + # one day based on api life cycle + cache_ttl: int = 3600 # 1 hour + redis_url: Optional[str] = "redis://localhost:6379" + use_redis: bool = False + + # ============ CORS Configuration ============ + allowed_origins: List[str] = [ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:8080", + ] + + # ============ Logging ============ + log_level: str = "INFO" + + @field_validator("debug", mode="before") + @classmethod + def parse_debug(cls, value: Any) -> Any: + if isinstance(value, str) and value.lower() in { + "release", + "prod", + "production", + }: + return False + return value + + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" + ) + + +@lru_cache() +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/database/__pycache__/__init__.cpython-311.pyc b/backend/app/database/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 6b2629ac..00000000 Binary files a/backend/app/database/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/backend/app/database/champion.json b/backend/app/database/champion.json new file mode 100644 index 00000000..ac729496 --- /dev/null +++ b/backend/app/database/champion.json @@ -0,0 +1 @@ +{"type":"champion","format":"standAloneComplex","version":"16.10.1","data":{"Aatrox":{"version":"16.10.1","id":"Aatrox","key":"266","name":"Aatrox","title":"the Darkin Blade","blurb":"Once honored defenders of Shurima against the Void, Aatrox and his brethren would eventually become an even greater threat to Runeterra, and were defeated only by cunning mortal sorcery. But after centuries of imprisonment, Aatrox was the first to find...","info":{"attack":8,"defense":4,"magic":3,"difficulty":4},"image":{"full":"Aatrox.png","sprite":"champion0.png","group":"champion","x":0,"y":0,"w":48,"h":48},"tags":["Fighter"],"partype":"Blood Well","stats":{"hp":650,"hpperlevel":114,"mp":0,"mpperlevel":0,"movespeed":345,"armor":38,"armorperlevel":4.8,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":3,"hpregenperlevel":0.5,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.651}},"Ahri":{"version":"16.10.1","id":"Ahri","key":"103","name":"Ahri","title":"the Nine-Tailed Fox","blurb":"Innately connected to the magic of the spirit realm, Ahri is a fox-like vastaya who can manipulate her prey's emotions and consume their essence—receiving flashes of their memory and insight from each soul she consumes. Once a powerful yet wayward...","info":{"attack":3,"defense":4,"magic":8,"difficulty":5},"image":{"full":"Ahri.png","sprite":"champion0.png","group":"champion","x":48,"y":0,"w":48,"h":48},"tags":["Mage","Assassin"],"partype":"Mana","stats":{"hp":590,"hpperlevel":104,"mp":418,"mpperlevel":25,"movespeed":330,"armor":21,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":2.5,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":53,"attackdamageperlevel":0,"attackspeedperlevel":2.2,"attackspeed":0.668}},"Akali":{"version":"16.10.1","id":"Akali","key":"84","name":"Akali","title":"the Rogue Assassin","blurb":"Abandoning the Kinkou Order and her title of the Fist of Shadow, Akali now strikes alone, ready to be the deadly weapon her people need. Though she holds onto all she learned from her master Shen, she has pledged to defend Ionia from its enemies, one...","info":{"attack":5,"defense":3,"magic":8,"difficulty":7},"image":{"full":"Akali.png","sprite":"champion0.png","group":"champion","x":96,"y":0,"w":48,"h":48},"tags":["Assassin"],"partype":"Energy","stats":{"hp":600,"hpperlevel":119,"mp":200,"mpperlevel":0,"movespeed":345,"armor":23,"armorperlevel":4.7,"spellblock":37,"spellblockperlevel":2.05,"attackrange":125,"hpregen":9,"hpregenperlevel":0.9,"mpregen":50,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":3.2,"attackspeed":0.625}},"Akshan":{"version":"16.10.1","id":"Akshan","key":"166","name":"Akshan","title":"the Rogue Sentinel","blurb":"Raising an eyebrow in the face of danger, Akshan fights evil with dashing charisma, righteous vengeance, and a conspicuous lack of shirts. He is highly skilled in the art of stealth combat, able to evade the eyes of his enemies and reappear when they...","info":{"attack":0,"defense":0,"magic":0,"difficulty":0},"image":{"full":"Akshan.png","sprite":"champion0.png","group":"champion","x":144,"y":0,"w":48,"h":48},"tags":["Marksman","Assassin"],"partype":"Mana","stats":{"hp":610,"hpperlevel":107,"mp":350,"mpperlevel":40,"movespeed":330,"armor":26,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":3.75,"hpregenperlevel":0.65,"mpregen":8.2,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":52,"attackdamageperlevel":0,"attackspeedperlevel":4,"attackspeed":0.638}},"Alistar":{"version":"16.10.1","id":"Alistar","key":"12","name":"Alistar","title":"the Minotaur","blurb":"Always a mighty warrior with a fearsome reputation, Alistar seeks revenge for the death of his clan at the hands of the Noxian empire. Though he was enslaved and forced into the life of a gladiator, his unbreakable will was what kept him from truly...","info":{"attack":6,"defense":9,"magic":5,"difficulty":7},"image":{"full":"Alistar.png","sprite":"champion0.png","group":"champion","x":192,"y":0,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":685,"hpperlevel":120,"mp":350,"mpperlevel":40,"movespeed":330,"armor":40,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.85,"mpregen":8.5,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":2.125,"attackspeed":0.625}},"Ambessa":{"version":"16.10.1","id":"Ambessa","key":"799","name":"Ambessa","title":"Matriarch of War","blurb":"All who know the name Medarda respect and fear the family's leader, Ambessa. As a Noxian general, she embodies a deadly combination of ruthless strength and fearless resolve in battle. Her role as matriarch is no different, requiring great cunning to...","info":{"attack":9,"defense":2,"magic":0,"difficulty":10},"image":{"full":"Ambessa.png","sprite":"champion0.png","group":"champion","x":240,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Energy","stats":{"hp":630,"hpperlevel":110,"mp":200,"mpperlevel":0,"movespeed":335,"armor":35,"armorperlevel":4.9,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.75,"mpregen":50,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.625}},"Amumu":{"version":"16.10.1","id":"Amumu","key":"32","name":"Amumu","title":"the Sad Mummy","blurb":"Legend claims that Amumu is a lonely and melancholy soul from ancient Shurima, roaming the world in search of a friend. Doomed by an ancient curse to remain alone forever, his touch is death, his affection ruin. Those who claim to have seen him describe...","info":{"attack":2,"defense":6,"magic":8,"difficulty":3},"image":{"full":"Amumu.png","sprite":"champion0.png","group":"champion","x":288,"y":0,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":685,"hpperlevel":94,"mp":285,"mpperlevel":40,"movespeed":335,"armor":33,"armorperlevel":4,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":9,"hpregenperlevel":0.85,"mpregen":7.4,"mpregenperlevel":0.55,"crit":0,"critperlevel":0,"attackdamage":57,"attackdamageperlevel":0,"attackspeedperlevel":2.18,"attackspeed":0.736}},"Anivia":{"version":"16.10.1","id":"Anivia","key":"34","name":"Anivia","title":"the Cryophoenix","blurb":"Anivia is a benevolent winged spirit who endures endless cycles of life, death, and rebirth to protect the Freljord. A demigod born of unforgiving ice and bitter winds, she wields those elemental powers to thwart any who dare disturb her homeland...","info":{"attack":1,"defense":4,"magic":10,"difficulty":10},"image":{"full":"Anivia.png","sprite":"champion0.png","group":"champion","x":336,"y":0,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":550,"hpperlevel":92,"mp":495,"mpperlevel":45,"movespeed":325,"armor":19,"armorperlevel":4.1,"spellblock":30,"spellblockperlevel":1.3,"attackrange":600,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":51,"attackdamageperlevel":0,"attackspeedperlevel":1.68,"attackspeed":0.658}},"Annie":{"version":"16.10.1","id":"Annie","key":"1","name":"Annie","title":"the Dark Child","blurb":"Dangerous, yet disarmingly precocious, Annie is a child mage with immense pyromantic power. Even in the shadows of the mountains north of Noxus, she is a magical outlier. Her natural affinity for fire manifested early in life through unpredictable...","info":{"attack":2,"defense":3,"magic":10,"difficulty":6},"image":{"full":"Annie.png","sprite":"champion0.png","group":"champion","x":384,"y":0,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":560,"hpperlevel":96,"mp":418,"mpperlevel":25,"movespeed":335,"armor":23,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":625,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":50,"attackdamageperlevel":0,"attackspeedperlevel":1.36,"attackspeed":0.61}},"Aphelios":{"version":"16.10.1","id":"Aphelios","key":"523","name":"Aphelios","title":"the Weapon of the Faithful","blurb":"Emerging from moonlight's shadow with weapons drawn, Aphelios kills the enemies of his faith in brooding silence—speaking only through the certainty of his aim, and the firing of each gun. Though fueled by a poison that renders him mute, he is guided by...","info":{"attack":6,"defense":2,"magic":1,"difficulty":10},"image":{"full":"Aphelios.png","sprite":"champion0.png","group":"champion","x":432,"y":0,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":600,"hpperlevel":102,"mp":348,"mpperlevel":42,"movespeed":325,"armor":26,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":3.25,"hpregenperlevel":0.55,"mpregen":6.5,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2.1,"attackspeed":0.665}},"Ashe":{"version":"16.10.1","id":"Ashe","key":"22","name":"Ashe","title":"the Frost Archer","blurb":"Iceborn warmother of the Avarosan tribe, Ashe commands the most populous horde in the north. Stoic, intelligent, and idealistic, yet uncomfortable with her role as leader, she taps into the ancestral magics of her lineage to wield a bow of True Ice...","info":{"attack":7,"defense":3,"magic":2,"difficulty":4},"image":{"full":"Ashe.png","sprite":"champion0.png","group":"champion","x":0,"y":48,"w":48,"h":48},"tags":["Marksman","Support"],"partype":"Mana","stats":{"hp":610,"hpperlevel":101,"mp":280,"mpperlevel":35,"movespeed":325,"armor":26,"armorperlevel":4.6,"spellblock":30,"spellblockperlevel":1.3,"attackrange":600,"hpregen":3.5,"hpregenperlevel":0.55,"mpregen":7,"mpregenperlevel":0.65,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.658}},"AurelionSol":{"version":"16.10.1","id":"AurelionSol","key":"136","name":"Aurelion Sol","title":"The Star Forger","blurb":"Aurelion Sol once graced the vast emptiness of the cosmos with celestial wonders of his own devising. Now, he is forced to wield his awesome power at the behest of a space-faring empire that tricked him into servitude. Desiring a return to his...","info":{"attack":2,"defense":3,"magic":8,"difficulty":7},"image":{"full":"AurelionSol.png","sprite":"champion0.png","group":"champion","x":48,"y":48,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":600,"hpperlevel":90,"mp":530,"mpperlevel":40,"movespeed":340,"armor":22,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":1.5,"attackspeed":0.625}},"Aurora":{"version":"16.10.1","id":"Aurora","key":"893","name":"Aurora","title":"the Witch Between Worlds","blurb":"From the moment she was born, Aurora navigated life with a unique ability to move between the spirit and material realms. Determined to learn more about the spirit realm's inhabitants, she left her home to further her research and happened upon a...","info":{"attack":3,"defense":4,"magic":8,"difficulty":5},"image":{"full":"Aurora.png","sprite":"champion0.png","group":"champion","x":96,"y":48,"w":48,"h":48},"tags":["Mage","Assassin"],"partype":"Mana","stats":{"hp":607,"hpperlevel":110,"mp":475,"mpperlevel":30,"movespeed":335,"armor":23,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":1.3,"attackrange":550,"hpregen":6,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":53,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.668}},"Azir":{"version":"16.10.1","id":"Azir","key":"268","name":"Azir","title":"the Emperor of the Sands","blurb":"Azir was a mortal emperor of Shurima in a far distant age, a proud man who stood at the cusp of immortality. His hubris saw him betrayed and murdered at the moment of his greatest triumph, but now, millennia later, he has been reborn as an Ascended...","info":{"attack":6,"defense":3,"magic":8,"difficulty":9},"image":{"full":"Azir.png","sprite":"champion0.png","group":"champion","x":144,"y":48,"w":48,"h":48},"tags":["Mage","Marksman"],"partype":"Mana","stats":{"hp":575,"hpperlevel":108,"mp":320,"mpperlevel":40,"movespeed":330,"armor":25,"armorperlevel":5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":7,"hpregenperlevel":0.75,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":56,"attackdamageperlevel":0,"attackspeedperlevel":5,"attackspeed":0.625}},"Bard":{"version":"16.10.1","id":"Bard","key":"432","name":"Bard","title":"the Wandering Caretaker","blurb":"A traveler from beyond the stars, Bard is an agent of serendipity who fights to maintain a balance where life can endure the indifference of chaos. Many Runeterrans sing songs that ponder his extraordinary nature, yet they all agree that the cosmic...","info":{"attack":4,"defense":4,"magic":5,"difficulty":9},"image":{"full":"Bard.png","sprite":"champion0.png","group":"champion","x":192,"y":48,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":630,"hpperlevel":103,"mp":350,"mpperlevel":50,"movespeed":335,"armor":34,"armorperlevel":5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":6,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":52,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.658}},"Belveth":{"version":"16.10.1","id":"Belveth","key":"200","name":"Bel'Veth","title":"the Empress of the Void","blurb":"A nightmarish empress created from the raw material of an entire devoured city, Bel'Veth is the end of Runeterra itself... and the beginning of a monstrous reality of her own design. Driven by epochs of repurposed history, knowledge, and memories from...","info":{"attack":4,"defense":2,"magic":7,"difficulty":10},"image":{"full":"Belveth.png","sprite":"champion0.png","group":"champion","x":240,"y":48,"w":48,"h":48},"tags":["Fighter"],"partype":"","stats":{"hp":610,"hpperlevel":105,"mp":60,"mpperlevel":0,"movespeed":340,"armor":32,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":6,"hpregenperlevel":0.6,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":0,"attackspeed":0.85}},"Blitzcrank":{"version":"16.10.1","id":"Blitzcrank","key":"53","name":"Blitzcrank","title":"the Great Steam Golem","blurb":"Blitzcrank is an enormous, near-indestructible automaton from Zaun, originally built to dispose of hazardous waste. However, he found this primary purpose too restricting, and modified his own form to better serve the fragile people of the Sump...","info":{"attack":4,"defense":8,"magic":5,"difficulty":4},"image":{"full":"Blitzcrank.png","sprite":"champion0.png","group":"champion","x":288,"y":48,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":600,"hpperlevel":109,"mp":267,"mpperlevel":40,"movespeed":325,"armor":37,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":7.5,"hpregenperlevel":0.75,"mpregen":8.5,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":1.13,"attackspeed":0.625}},"Brand":{"version":"16.10.1","id":"Brand","key":"63","name":"Brand","title":"the Burning Vengeance","blurb":"Once a tribesman of the icy Freljord named Kegan Rodhe, the creature known as Brand is a lesson in the temptation of greater power. Seeking one of the legendary World Runes, Kegan betrayed his companions and seized it for himself—and, in an instant, the...","info":{"attack":2,"defense":2,"magic":9,"difficulty":4},"image":{"full":"Brand.png","sprite":"champion0.png","group":"champion","x":336,"y":48,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":570,"hpperlevel":105,"mp":469,"mpperlevel":21,"movespeed":340,"armor":27,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":9,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":57,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.681}},"Braum":{"version":"16.10.1","id":"Braum","key":"201","name":"Braum","title":"the Heart of the Freljord","blurb":"Blessed with massive biceps and an even bigger heart, Braum is a beloved hero of the Freljord. Every mead hall north of Frostheld toasts his legendary strength, said to have felled a forest of oaks in a single night, and punched an entire mountain into...","info":{"attack":3,"defense":9,"magic":4,"difficulty":3},"image":{"full":"Braum.png","sprite":"champion0.png","group":"champion","x":384,"y":48,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":610,"hpperlevel":112,"mp":311,"mpperlevel":45,"movespeed":335,"armor":35,"armorperlevel":5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":1,"mpregen":7,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.644}},"Briar":{"version":"16.10.1","id":"Briar","key":"233","name":"Briar","title":"the Restrained Hunger","blurb":"A failed experiment by the Black Rose, Briar's uncontrollable bloodlust required a special pillory to focus her frenzied mind. After years of confinement, this living weapon broke free from her restraints and unleashed herself into the world. Now she's...","info":{"attack":9,"defense":5,"magic":3,"difficulty":3},"image":{"full":"Briar.png","sprite":"champion0.png","group":"champion","x":432,"y":48,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Fury","stats":{"hp":625,"hpperlevel":95,"mp":0,"mpperlevel":0,"movespeed":340,"armor":30,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":0,"hpregenperlevel":0,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.644}},"Caitlyn":{"version":"16.10.1","id":"Caitlyn","key":"51","name":"Caitlyn","title":"the Sheriff of Piltover","blurb":"Renowned as its finest peacekeeper, Caitlyn Kiramman is also Piltover's best shot at ridding the city of its elusive criminal elements. She is often paired with Vi, acting as a cool counterpoint to her partner's more impetuous nature. Even though she...","info":{"attack":8,"defense":2,"magic":2,"difficulty":6},"image":{"full":"Caitlyn.png","sprite":"champion0.png","group":"champion","x":0,"y":96,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":580,"hpperlevel":107,"mp":315,"mpperlevel":40,"movespeed":325,"armor":27,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":650,"hpregen":3.5,"hpregenperlevel":0.55,"mpregen":7.4,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":4,"attackspeed":0.681}},"Camille":{"version":"16.10.1","id":"Camille","key":"164","name":"Camille","title":"the Steel Shadow","blurb":"Weaponized to operate outside the boundaries of the law, Camille is the Principal Intelligencer of Clan Ferros—an elegant and elite agent who ensures the Piltover machine and its Zaunite underbelly runs smoothly. Adaptable and precise, she views sloppy...","info":{"attack":8,"defense":6,"magic":3,"difficulty":4},"image":{"full":"Camille.png","sprite":"champion0.png","group":"champion","x":48,"y":96,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":650,"hpperlevel":99,"mp":339,"mpperlevel":52,"movespeed":340,"armor":35,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.8,"mpregen":8.15,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":68,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.644}},"Cassiopeia":{"version":"16.10.1","id":"Cassiopeia","key":"69","name":"Cassiopeia","title":"the Serpent's Embrace","blurb":"Cassiopeia is a deadly creature bent on manipulating others to her sinister will. Youngest and most beautiful daughter of the noble Du Couteau family of Noxus, she ventured deep into the crypts beneath Shurima in search of ancient power. There, she was...","info":{"attack":2,"defense":3,"magic":9,"difficulty":10},"image":{"full":"Cassiopeia.png","sprite":"champion0.png","group":"champion","x":96,"y":96,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":630,"hpperlevel":104,"mp":480,"mpperlevel":40,"movespeed":335,"armor":18,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.5,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":53,"attackdamageperlevel":0,"attackspeedperlevel":1.5,"attackspeed":0.647}},"Chogath":{"version":"16.10.1","id":"Chogath","key":"31","name":"Cho'Gath","title":"the Terror of the Void","blurb":"From the moment Cho'Gath first emerged into the harsh light of Runeterra's sun, the beast was driven by the most pure and insatiable hunger. A perfect expression of the Void's desire to consume all life, Cho'Gath's complex biology quickly converts...","info":{"attack":3,"defense":7,"magic":7,"difficulty":5},"image":{"full":"Chogath.png","sprite":"champion0.png","group":"champion","x":144,"y":96,"w":48,"h":48},"tags":["Tank","Mage"],"partype":"Mana","stats":{"hp":644,"hpperlevel":94,"mp":270,"mpperlevel":60,"movespeed":345,"armor":38,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":9,"hpregenperlevel":0.85,"mpregen":7.2,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":69,"attackdamageperlevel":0,"attackspeedperlevel":1.44,"attackspeed":0.658}},"Corki":{"version":"16.10.1","id":"Corki","key":"42","name":"Corki","title":"the Daring Bombardier","blurb":"The yordle pilot Corki loves two things above all others: flying, and his glamorous mustache... though not necessarily in that order. After leaving Bandle City, he settled in Piltover and fell in love with the wondrous machines he found there. He...","info":{"attack":8,"defense":3,"magic":6,"difficulty":6},"image":{"full":"Corki.png","sprite":"champion0.png","group":"champion","x":192,"y":96,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":610,"hpperlevel":100,"mp":350,"mpperlevel":40,"movespeed":325,"armor":27,"armorperlevel":4.5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":7.4,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":52,"attackdamageperlevel":0,"attackspeedperlevel":2.8,"attackspeed":0.644}},"Darius":{"version":"16.10.1","id":"Darius","key":"122","name":"Darius","title":"the Hand of Noxus","blurb":"There is no greater symbol of Noxian might than Darius, the nation's most feared and battle-hardened commander. Rising from humble origins to become the Hand of Noxus, he cleaves through the empire's enemies—many of them Noxians themselves. Knowing that...","info":{"attack":9,"defense":5,"magic":1,"difficulty":2},"image":{"full":"Darius.png","sprite":"champion0.png","group":"champion","x":240,"y":96,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":652,"hpperlevel":114,"mp":263,"mpperlevel":58,"movespeed":340,"armor":37,"armorperlevel":5.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":10,"hpregenperlevel":0.95,"mpregen":6.6,"mpregenperlevel":0.35,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":1,"attackspeed":0.625}},"Diana":{"version":"16.10.1","id":"Diana","key":"131","name":"Diana","title":"Scorn of the Moon","blurb":"Bearing her crescent moonblade, Diana fights as a warrior of the Lunari—a faith all but quashed in the lands around Mount Targon. Clad in shimmering armor the color of winter snow at night, she is a living embodiment of the silver moon's power. Imbued...","info":{"attack":7,"defense":6,"magic":8,"difficulty":4},"image":{"full":"Diana.png","sprite":"champion0.png","group":"champion","x":288,"y":96,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":640,"hpperlevel":109,"mp":375,"mpperlevel":25,"movespeed":345,"armor":31,"armorperlevel":4.3,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":6.5,"hpregenperlevel":0.85,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":57,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.625}},"Draven":{"version":"16.10.1","id":"Draven","key":"119","name":"Draven","title":"the Glorious Executioner","blurb":"In Noxus, warriors known as Reckoners face one another in arenas where blood is spilled and strength tested—but none has ever been as celebrated as Draven. A former soldier, he found that the crowds uniquely appreciated his flair for the dramatic, and...","info":{"attack":9,"defense":3,"magic":1,"difficulty":8},"image":{"full":"Draven.png","sprite":"champion0.png","group":"champion","x":336,"y":96,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":675,"hpperlevel":104,"mp":361,"mpperlevel":39,"movespeed":330,"armor":29,"armorperlevel":4.5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":3.75,"hpregenperlevel":0.7,"mpregen":8.05,"mpregenperlevel":0.65,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":2.7,"attackspeed":0.679}},"DrMundo":{"version":"16.10.1","id":"DrMundo","key":"36","name":"Dr. Mundo","title":"the Madman of Zaun","blurb":"Utterly mad, tragically homicidal, and horrifyingly purple, Dr. Mundo is what keeps many of Zaun's citizens indoors on particularly dark nights. Now a self-proclaimed physician, he was once a patient of Zaun's most infamous asylum. After \"curing\" the...","info":{"attack":5,"defense":7,"magic":6,"difficulty":5},"image":{"full":"DrMundo.png","sprite":"champion0.png","group":"champion","x":384,"y":96,"w":48,"h":48},"tags":["Tank","Fighter"],"partype":"None","stats":{"hp":640,"hpperlevel":103,"mp":0,"mpperlevel":0,"movespeed":345,"armor":32,"armorperlevel":4.5,"spellblock":29,"spellblockperlevel":2.3,"attackrange":125,"hpregen":7,"hpregenperlevel":0.5,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":3.3,"attackspeed":0.67}},"Ekko":{"version":"16.10.1","id":"Ekko","key":"245","name":"Ekko","title":"the Boy Who Shattered Time","blurb":"A prodigy from the rough streets of Zaun, Ekko is able to manipulate time to twist any situation to his advantage. He uses his own invention, the Z-Drive, to explore the branching possibilities of reality, crafting the perfect moment to seemingly...","info":{"attack":5,"defense":3,"magic":7,"difficulty":8},"image":{"full":"Ekko.png","sprite":"champion0.png","group":"champion","x":432,"y":96,"w":48,"h":48},"tags":["Assassin","Mage"],"partype":"Mana","stats":{"hp":655,"hpperlevel":99,"mp":280,"mpperlevel":70,"movespeed":340,"armor":32,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":9,"hpregenperlevel":0.9,"mpregen":7,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":3.3,"attackspeed":0.688}},"Elise":{"version":"16.10.1","id":"Elise","key":"60","name":"Elise","title":"the Spider Queen","blurb":"Elise is a deadly predator who dwells in a shuttered, lightless palace, deep within the oldest city of Noxus. Once mortal, she was the mistress of a powerful house, but the bite of a vile demigod transformed her into something beautiful, yet utterly...","info":{"attack":6,"defense":5,"magic":7,"difficulty":9},"image":{"full":"Elise.png","sprite":"champion1.png","group":"champion","x":0,"y":0,"w":48,"h":48},"tags":["Assassin","Mage"],"partype":"Mana","stats":{"hp":620,"hpperlevel":109,"mp":324,"mpperlevel":50,"movespeed":330,"armor":30,"armorperlevel":4.5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.6,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":1.75,"attackspeed":0.625}},"Evelynn":{"version":"16.10.1","id":"Evelynn","key":"28","name":"Evelynn","title":"Agony's Embrace","blurb":"Within the dark seams of Runeterra, the demon Evelynn searches for her next victim. She lures in prey with the voluptuous façade of a human female, but once a person succumbs to her charms, Evelynn's true form is unleashed. She then subjects her victim...","info":{"attack":4,"defense":2,"magic":7,"difficulty":10},"image":{"full":"Evelynn.png","sprite":"champion1.png","group":"champion","x":48,"y":0,"w":48,"h":48},"tags":["Assassin","Mage"],"partype":"Mana","stats":{"hp":642,"hpperlevel":98,"mp":315,"mpperlevel":42,"movespeed":335,"armor":37,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.75,"mpregen":8.11,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":2.1,"attackspeed":0.667}},"Ezreal":{"version":"16.10.1","id":"Ezreal","key":"81","name":"Ezreal","title":"the Prodigal Explorer","blurb":"A dashing adventurer, unknowingly gifted in the magical arts, Ezreal raids long-lost catacombs, tangles with ancient curses, and overcomes seemingly impossible odds with ease. His courage and bravado knowing no bounds, he prefers to improvise his way...","info":{"attack":7,"defense":2,"magic":6,"difficulty":7},"image":{"full":"Ezreal.png","sprite":"champion1.png","group":"champion","x":96,"y":0,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":600,"hpperlevel":102,"mp":375,"mpperlevel":70,"movespeed":325,"armor":24,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":4,"hpregenperlevel":0.65,"mpregen":8.5,"mpregenperlevel":1,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.625}},"Fiddlesticks":{"version":"16.10.1","id":"Fiddlesticks","key":"9","name":"Fiddlesticks","title":"the Ancient Fear","blurb":"Something has awoken in Runeterra. Something ancient. Something terrible. The ageless horror known as Fiddlesticks stalks the edges of mortal society, drawn to areas thick with paranoia where it feeds upon terrorized victims. Wielding a jagged scythe...","info":{"attack":2,"defense":3,"magic":9,"difficulty":9},"image":{"full":"Fiddlesticks.png","sprite":"champion1.png","group":"champion","x":144,"y":0,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":650,"hpperlevel":106,"mp":500,"mpperlevel":28,"movespeed":335,"armor":34,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":480,"hpregen":5.5,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2.11,"attackspeed":0.625}},"Fiora":{"version":"16.10.1","id":"Fiora","key":"114","name":"Fiora","title":"the Grand Duelist","blurb":"The most feared duelist in all Valoran, Fiora is as renowned for her brusque manner and cunning mind as she is for the speed of her bluesteel rapier. Born to House Laurent in the kingdom of Demacia, Fiora took control of the family from her father in...","info":{"attack":10,"defense":4,"magic":2,"difficulty":3},"image":{"full":"Fiora.png","sprite":"champion1.png","group":"champion","x":192,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":620,"hpperlevel":99,"mp":300,"mpperlevel":60,"movespeed":345,"armor":33,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":8.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":66,"attackdamageperlevel":0,"attackspeedperlevel":3.2,"attackspeed":0.69}},"Fizz":{"version":"16.10.1","id":"Fizz","key":"105","name":"Fizz","title":"the Tidal Trickster","blurb":"Fizz is an amphibious yordle, who dwells among the reefs surrounding Bilgewater. He often retrieves and returns the tithes cast into the sea by superstitious captains, but even the saltiest of sailors know better than to cross him—for many are the tales...","info":{"attack":6,"defense":4,"magic":7,"difficulty":6},"image":{"full":"Fizz.png","sprite":"champion1.png","group":"champion","x":240,"y":0,"w":48,"h":48},"tags":["Assassin","Fighter"],"partype":"Mana","stats":{"hp":640,"hpperlevel":106,"mp":317,"mpperlevel":52,"movespeed":335,"armor":26,"armorperlevel":4.6,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":8,"hpregenperlevel":0.7,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":3.1,"attackspeed":0.658}},"Galio":{"version":"16.10.1","id":"Galio","key":"3","name":"Galio","title":"the Colossus","blurb":"Outside the gleaming city of Demacia, the stone colossus Galio keeps vigilant watch. Built as a bulwark against enemy mages, he often stands motionless for decades until the presence of powerful magic stirs him to life. Once activated, Galio makes the...","info":{"attack":1,"defense":10,"magic":6,"difficulty":5},"image":{"full":"Galio.png","sprite":"champion1.png","group":"champion","x":288,"y":0,"w":48,"h":48},"tags":["Tank","Mage"],"partype":"Mana","stats":{"hp":600,"hpperlevel":126,"mp":410,"mpperlevel":40,"movespeed":340,"armor":24,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":8,"hpregenperlevel":0.8,"mpregen":9.5,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":1.5,"attackspeed":0.625}},"Gangplank":{"version":"16.10.1","id":"Gangplank","key":"41","name":"Gangplank","title":"the Saltwater Scourge","blurb":"As unpredictable as he is brutal, the dethroned reaver king Gangplank is feared far and wide. Once, he ruled the port city of Bilgewater, and while his reign is over, there are those who believe this has only made him more dangerous. Gangplank would see...","info":{"attack":7,"defense":6,"magic":4,"difficulty":9},"image":{"full":"Gangplank.png","sprite":"champion1.png","group":"champion","x":336,"y":0,"w":48,"h":48},"tags":["Fighter"],"partype":"Mana","stats":{"hp":630,"hpperlevel":114,"mp":280,"mpperlevel":60,"movespeed":345,"armor":31,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":6,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":3.2,"attackspeed":0.658}},"Garen":{"version":"16.10.1","id":"Garen","key":"86","name":"Garen","title":"The Might of Demacia","blurb":"A proud and noble warrior, Garen fights as one of the Dauntless Vanguard. He is popular among his fellows, and respected well enough by his enemies—not least as a scion of the prestigious Crownguard family, entrusted with defending Demacia and its...","info":{"attack":7,"defense":7,"magic":1,"difficulty":5},"image":{"full":"Garen.png","sprite":"champion1.png","group":"champion","x":384,"y":0,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"None","stats":{"hp":690,"hpperlevel":98,"mp":0,"mpperlevel":0,"movespeed":340,"armor":38,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":1.55,"attackrange":175,"hpregen":8,"hpregenperlevel":0.5,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":69,"attackdamageperlevel":0,"attackspeedperlevel":3.65,"attackspeed":0.625}},"Gnar":{"version":"16.10.1","id":"Gnar","key":"150","name":"Gnar","title":"the Missing Link","blurb":"Gnar is a primeval yordle whose playful antics can erupt into a toddler's outrage in an instant, transforming him into a massive beast bent on destruction. Frozen in True Ice for millennia, the curious creature broke free and now hops about a changed...","info":{"attack":6,"defense":5,"magic":5,"difficulty":8},"image":{"full":"Gnar.png","sprite":"champion1.png","group":"champion","x":432,"y":0,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Rage","stats":{"hp":540,"hpperlevel":79,"mp":100,"mpperlevel":0,"movespeed":335,"armor":32,"armorperlevel":3.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":175,"hpregen":4.5,"hpregenperlevel":1.25,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":6,"attackspeed":0.625}},"Gragas":{"version":"16.10.1","id":"Gragas","key":"79","name":"Gragas","title":"the Rabble Rouser","blurb":"Equal parts jolly and imposing, Gragas is a massive, rowdy brewmaster who's always on the lookout for new ways to raise everyone's spirits. Hailing from parts unknown, he searches for ingredients among the unblemished wastes of the Freljord to help him...","info":{"attack":4,"defense":7,"magic":6,"difficulty":5},"image":{"full":"Gragas.png","sprite":"champion1.png","group":"champion","x":0,"y":48,"w":48,"h":48},"tags":["Fighter","Mage"],"partype":"Mana","stats":{"hp":640,"hpperlevel":115,"mp":400,"mpperlevel":47,"movespeed":330,"armor":38,"armorperlevel":5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":5.5,"hpregenperlevel":0.5,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":2.05,"attackspeed":0.675}},"Graves":{"version":"16.10.1","id":"Graves","key":"104","name":"Graves","title":"the Outlaw","blurb":"Malcolm Graves is a renowned mercenary, gambler, and thief—a wanted man in every city and empire he has visited. Even though he has an explosive temper, he possesses a strict sense of criminal honor, often enforced at the business end of his...","info":{"attack":8,"defense":5,"magic":3,"difficulty":3},"image":{"full":"Graves.png","sprite":"champion1.png","group":"champion","x":48,"y":48,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":625,"hpperlevel":106,"mp":325,"mpperlevel":40,"movespeed":340,"armor":33,"armorperlevel":4.6,"spellblock":30,"spellblockperlevel":1.3,"attackrange":425,"hpregen":8,"hpregenperlevel":0.7,"mpregen":8,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":66,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.475}},"Gwen":{"version":"16.10.1","id":"Gwen","key":"887","name":"Gwen","title":"The Hallowed Seamstress","blurb":"A former doll transformed and brought to life by magic, Gwen wields the very tools that once created her. She carries the weight of her maker's love with every step, taking nothing for granted. At her command is the Hallowed Mist, an ancient and...","info":{"attack":7,"defense":4,"magic":5,"difficulty":5},"image":{"full":"Gwen.png","sprite":"champion1.png","group":"champion","x":96,"y":48,"w":48,"h":48},"tags":["Fighter"],"partype":"Mana","stats":{"hp":620,"hpperlevel":115,"mp":330,"mpperlevel":40,"movespeed":340,"armor":39,"armorperlevel":4.9,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":9,"hpregenperlevel":0.9,"mpregen":7.5,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":2.25,"attackspeed":0.69}},"Hecarim":{"version":"16.10.1","id":"Hecarim","key":"120","name":"Hecarim","title":"the Shadow of War","blurb":"Hecarim is a spectral fusion of man and beast, cursed to ride down the souls of the living for all eternity. When the Blessed Isles fell into shadow, this proud knight was obliterated by the destructive energies of the Ruination, along with all his...","info":{"attack":8,"defense":6,"magic":4,"difficulty":6},"image":{"full":"Hecarim.png","sprite":"champion1.png","group":"champion","x":144,"y":48,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":625,"hpperlevel":106,"mp":280,"mpperlevel":40,"movespeed":345,"armor":32,"armorperlevel":5.45,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":7,"hpregenperlevel":0.75,"mpregen":7,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":66,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.67}},"Heimerdinger":{"version":"16.10.1","id":"Heimerdinger","key":"74","name":"Heimerdinger","title":"the Revered Inventor","blurb":"The eccentric Professor Cecil B. Heimerdinger is one of the most innovative and esteemed inventors the world has ever known. As the longest serving member of the Council of Piltover, he saw the best and the worst of the city's unending desire for...","info":{"attack":2,"defense":6,"magic":8,"difficulty":8},"image":{"full":"Heimerdinger.png","sprite":"champion1.png","group":"champion","x":192,"y":48,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":558,"hpperlevel":105,"mp":385,"mpperlevel":20,"movespeed":340,"armor":19,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":7,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":56,"attackdamageperlevel":0,"attackspeedperlevel":1.36,"attackspeed":0.658}},"Hwei":{"version":"16.10.1","id":"Hwei","key":"910","name":"Hwei","title":"the Visionary","blurb":"Hwei is a brooding painter who creates brilliant art in order to confront Ionia's criminals and comfort their victims. Beneath his melancholy roils a torn, emotional mind—haunted by both the vibrant visions of his imagination and the gruesome memories...","info":{"attack":7,"defense":1,"magic":8,"difficulty":9},"image":{"full":"Hwei.png","sprite":"champion1.png","group":"champion","x":240,"y":48,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":580,"hpperlevel":109,"mp":480,"mpperlevel":30,"movespeed":330,"armor":21,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":7.5,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":54,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.69}},"Illaoi":{"version":"16.10.1","id":"Illaoi","key":"420","name":"Illaoi","title":"the Kraken Priestess","blurb":"Illaoi's powerful physique is dwarfed only by her indomitable faith. As the prophet of the Great Kraken, she uses a huge, golden idol to rip her foes' spirits from their bodies and shatter their perception of reality. All who challenge the “Truth Bearer...","info":{"attack":8,"defense":6,"magic":3,"difficulty":4},"image":{"full":"Illaoi.png","sprite":"champion1.png","group":"champion","x":288,"y":48,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":656,"hpperlevel":115,"mp":350,"mpperlevel":50,"movespeed":350,"armor":35,"armorperlevel":5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":9.5,"hpregenperlevel":0.8,"mpregen":7.5,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":65,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.625}},"Irelia":{"version":"16.10.1","id":"Irelia","key":"39","name":"Irelia","title":"the Blade Dancer","blurb":"The Noxian occupation of Ionia produced many heroes, none more unlikely than young Irelia of Navori. Trained in the ancient dances of her province, she adapted her art for war, using the graceful and carefully practised movements to levitate a host of...","info":{"attack":7,"defense":4,"magic":5,"difficulty":5},"image":{"full":"Irelia.png","sprite":"champion1.png","group":"champion","x":336,"y":48,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":630,"hpperlevel":115,"mp":350,"mpperlevel":50,"movespeed":335,"armor":36,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":2.05,"attackrange":200,"hpregen":3.5,"hpregenperlevel":0.85,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":65,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.656}},"Ivern":{"version":"16.10.1","id":"Ivern","key":"427","name":"Ivern","title":"the Green Father","blurb":"Ivern Bramblefoot, known to many as the Green Father, is a peculiar half man, half tree who roams Runeterra's forests, cultivating life everywhere he goes. He knows the secrets of the natural world, and holds deep friendships with all things that grow...","info":{"attack":3,"defense":5,"magic":7,"difficulty":7},"image":{"full":"Ivern.png","sprite":"champion1.png","group":"champion","x":384,"y":48,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":630,"hpperlevel":99,"mp":450,"mpperlevel":60,"movespeed":330,"armor":27,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":475,"hpregen":7,"hpregenperlevel":0.85,"mpregen":6,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":50,"attackdamageperlevel":0,"attackspeedperlevel":3.4,"attackspeed":0.644}},"Janna":{"version":"16.10.1","id":"Janna","key":"40","name":"Janna","title":"the Storm's Fury","blurb":"Armed with the power of Runeterra's gales, Janna is a mysterious, elemental wind spirit who protects the dispossessed of Zaun. Some believe she was brought into existence by the pleas of Runeterra's sailors who prayed for fair winds as they navigated...","info":{"attack":3,"defense":5,"magic":7,"difficulty":7},"image":{"full":"Janna.png","sprite":"champion1.png","group":"champion","x":432,"y":48,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":570,"hpperlevel":90,"mp":360,"mpperlevel":50,"movespeed":325,"armor":28,"armorperlevel":4.5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":11.5,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":47,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.625}},"JarvanIV":{"version":"16.10.1","id":"JarvanIV","key":"59","name":"Jarvan IV","title":"the Exemplar of Demacia","blurb":"Prince Jarvan, scion of the Lightshield dynasty, is heir apparent to the throne of Demacia. Raised to be a paragon of his nation's greatest virtues, he is forced to balance the heavy expectations placed upon him with his own desire to fight on the front...","info":{"attack":6,"defense":8,"magic":3,"difficulty":5},"image":{"full":"JarvanIV.png","sprite":"champion1.png","group":"champion","x":0,"y":96,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":640,"hpperlevel":104,"mp":300,"mpperlevel":55,"movespeed":340,"armor":36,"armorperlevel":4.6,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":8,"hpregenperlevel":0.7,"mpregen":6.5,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.658}},"Jax":{"version":"16.10.1","id":"Jax","key":"24","name":"Jax","title":"Grandmaster at Arms","blurb":"Unmatched in both his skill with unique armaments and his biting sarcasm, Jax is the last known weapons master of Icathia. After his homeland was laid low by its own hubris in unleashing the Void, Jax and his kind vowed to protect what little remained...","info":{"attack":7,"defense":5,"magic":7,"difficulty":5},"image":{"full":"Jax.png","sprite":"champion1.png","group":"champion","x":48,"y":96,"w":48,"h":48},"tags":["Fighter"],"partype":"Mana","stats":{"hp":650,"hpperlevel":103,"mp":339,"mpperlevel":52,"movespeed":350,"armor":36,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.55,"mpregen":8.2,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":68,"attackdamageperlevel":0,"attackspeedperlevel":3.4,"attackspeed":0.638}},"Jayce":{"version":"16.10.1","id":"Jayce","key":"126","name":"Jayce","title":"the Defender of Tomorrow","blurb":"Jayce Talis is a brilliant inventor who, along with his friend Viktor, made the first great discoveries in the field of hextech. Celebrated across Piltover, he tries to live up to his reputation as \"the Man of Progress,\" but often struggles with the...","info":{"attack":8,"defense":4,"magic":3,"difficulty":7},"image":{"full":"Jayce.png","sprite":"champion1.png","group":"champion","x":96,"y":96,"w":48,"h":48},"tags":["Fighter","Marksman"],"partype":"Mana","stats":{"hp":590,"hpperlevel":109,"mp":375,"mpperlevel":45,"movespeed":335,"armor":22,"armorperlevel":5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":125,"hpregen":6,"hpregenperlevel":0.6,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.658}},"Jhin":{"version":"16.10.1","id":"Jhin","key":"202","name":"Jhin","title":"the Virtuoso","blurb":"Jhin is a meticulous criminal psychopath who believes murder is art. Once an Ionian prisoner, but freed by shadowy elements within Ionia's ruling council, the serial killer now works as their cabal's assassin. Using his gun as his paintbrush, Jhin...","info":{"attack":10,"defense":2,"magic":6,"difficulty":6},"image":{"full":"Jhin.png","sprite":"champion1.png","group":"champion","x":144,"y":96,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":655,"hpperlevel":107,"mp":300,"mpperlevel":50,"movespeed":330,"armor":24,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":3.75,"hpregenperlevel":0.55,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":0,"attackspeed":0.625}},"Jinx":{"version":"16.10.1","id":"Jinx","key":"222","name":"Jinx","title":"the Loose Cannon","blurb":"An unhinged and impulsive criminal from the undercity, Jinx is haunted by the consequences of her past—but that doesn't stop her from bringing her own chaotic brand of pandemonium to Piltover and Zaun. She uses her arsenal of DIY weapons to devastating...","info":{"attack":9,"defense":2,"magic":4,"difficulty":6},"image":{"full":"Jinx.png","sprite":"champion1.png","group":"champion","x":192,"y":96,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":630,"hpperlevel":105,"mp":260,"mpperlevel":50,"movespeed":325,"armor":26,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":3.75,"hpregenperlevel":0.5,"mpregen":6.7,"mpregenperlevel":1,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":1,"attackspeed":0.625}},"Kaisa":{"version":"16.10.1","id":"Kaisa","key":"145","name":"Kai'Sa","title":"Daughter of the Void","blurb":"Claimed by the Void when she was only a child, Kai'Sa managed to survive through sheer tenacity and strength of will. Her experiences have made her a deadly hunter and, to some, the harbinger of a future they would rather not live to see. Having entered...","info":{"attack":8,"defense":5,"magic":3,"difficulty":6},"image":{"full":"Kaisa.png","sprite":"champion1.png","group":"champion","x":240,"y":96,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":640,"hpperlevel":102,"mp":345,"mpperlevel":40,"movespeed":335,"armor":25,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":4,"hpregenperlevel":0.55,"mpregen":8.2,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":1.8,"attackspeed":0.644}},"Kalista":{"version":"16.10.1","id":"Kalista","key":"429","name":"Kalista","title":"the Spear of Vengeance","blurb":"A specter of wrath and retribution, Kalista is the undying spirit of vengeance, an armored nightmare summoned from the Shadow Isles to hunt deceivers and traitors. The betrayed may cry out in blood to be avenged, but Kalista only answers those willing...","info":{"attack":8,"defense":2,"magic":4,"difficulty":7},"image":{"full":"Kalista.png","sprite":"champion1.png","group":"champion","x":288,"y":96,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":560,"hpperlevel":114,"mp":300,"mpperlevel":45,"movespeed":330,"armor":24,"armorperlevel":5.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":4,"hpregenperlevel":0.75,"mpregen":6.3,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":57,"attackdamageperlevel":0,"attackspeedperlevel":4.5,"attackspeed":0.694}},"Karma":{"version":"16.10.1","id":"Karma","key":"43","name":"Karma","title":"the Enlightened One","blurb":"No mortal exemplifies the spiritual traditions of Ionia more than Karma. She is the living embodiment of an ancient soul reincarnated countless times, carrying all her accumulated memories into each new life, and blessed with power that few can...","info":{"attack":1,"defense":7,"magic":8,"difficulty":5},"image":{"full":"Karma.png","sprite":"champion1.png","group":"champion","x":336,"y":96,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":630,"hpperlevel":109,"mp":374,"mpperlevel":40,"movespeed":335,"armor":28,"armorperlevel":5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":13,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":49,"attackdamageperlevel":0,"attackspeedperlevel":2.3,"attackspeed":0.625}},"Karthus":{"version":"16.10.1","id":"Karthus","key":"30","name":"Karthus","title":"the Deathsinger","blurb":"The harbinger of oblivion, Karthus is an undying spirit whose haunting songs are a prelude to the horror of his nightmarish appearance. The living fear the eternity of undeath, but Karthus sees only beauty and purity in its embrace, a perfect union of...","info":{"attack":2,"defense":2,"magic":10,"difficulty":7},"image":{"full":"Karthus.png","sprite":"champion1.png","group":"champion","x":384,"y":96,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":620,"hpperlevel":110,"mp":467,"mpperlevel":31,"movespeed":335,"armor":21,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":450,"hpregen":6.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":46,"attackdamageperlevel":0,"attackspeedperlevel":2.11,"attackspeed":0.625}},"Kassadin":{"version":"16.10.1","id":"Kassadin","key":"38","name":"Kassadin","title":"the Void Walker","blurb":"Cutting a burning swath through the darkest places of the world, Kassadin knows his days are numbered. A widely traveled Shuriman guide and adventurer, he had chosen to raise a family among the peaceful southern tribes—until the day his village was...","info":{"attack":3,"defense":5,"magic":8,"difficulty":8},"image":{"full":"Kassadin.png","sprite":"champion1.png","group":"champion","x":432,"y":96,"w":48,"h":48},"tags":["Assassin","Mage"],"partype":"Mana","stats":{"hp":646,"hpperlevel":113,"mp":400,"mpperlevel":87,"movespeed":335,"armor":21,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":150,"hpregen":6,"hpregenperlevel":0.5,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":3.7,"attackspeed":0.64}},"Katarina":{"version":"16.10.1","id":"Katarina","key":"55","name":"Katarina","title":"the Sinister Blade","blurb":"Decisive in judgment and lethal in combat, Katarina is a Noxian assassin of the highest caliber. Eldest daughter to the legendary General Du Couteau, she made her talents known with swift kills against unsuspecting enemies. Her fiery ambition has driven...","info":{"attack":4,"defense":3,"magic":9,"difficulty":8},"image":{"full":"Katarina.png","sprite":"champion2.png","group":"champion","x":0,"y":0,"w":48,"h":48},"tags":["Assassin","Mage"],"partype":"None","stats":{"hp":672,"hpperlevel":108,"mp":0,"mpperlevel":0,"movespeed":335,"armor":32,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":7.5,"hpregenperlevel":0.7,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":2.74,"attackspeed":0.658}},"Kayle":{"version":"16.10.1","id":"Kayle","key":"10","name":"Kayle","title":"the Righteous","blurb":"Born to a Targonian Aspect at the height of the Rune Wars, Kayle honored her mother's legacy by fighting for justice on wings of divine flame. She and her twin sister Morgana were the protectors of Demacia for many years—until Kayle became disillusioned...","info":{"attack":6,"defense":6,"magic":7,"difficulty":7},"image":{"full":"Kayle.png","sprite":"champion2.png","group":"champion","x":48,"y":0,"w":48,"h":48},"tags":["Mage","Marksman"],"partype":"Mana","stats":{"hp":670,"hpperlevel":92,"mp":330,"mpperlevel":50,"movespeed":335,"armor":26,"armorperlevel":4.2,"spellblock":22,"spellblockperlevel":1.3,"attackrange":175,"hpregen":5,"hpregenperlevel":0.5,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":50,"attackdamageperlevel":0,"attackspeedperlevel":1.5,"attackspeed":0.625}},"Kayn":{"version":"16.10.1","id":"Kayn","key":"141","name":"Kayn","title":"the Shadow Reaper","blurb":"A peerless practitioner of lethal shadow magic, Shieda Kayn battles to achieve his true destiny—to one day lead the Order of Shadow into a new era of Ionian supremacy. He wields the sentient darkin weapon Rhaast, undeterred by its creeping corruption of...","info":{"attack":10,"defense":6,"magic":1,"difficulty":8},"image":{"full":"Kayn.png","sprite":"champion2.png","group":"champion","x":96,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":655,"hpperlevel":103,"mp":410,"mpperlevel":50,"movespeed":340,"armor":38,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":8,"hpregenperlevel":0.75,"mpregen":11.5,"mpregenperlevel":0.95,"crit":0,"critperlevel":0,"attackdamage":68,"attackdamageperlevel":0,"attackspeedperlevel":2.7,"attackspeed":0.669}},"Kennen":{"version":"16.10.1","id":"Kennen","key":"85","name":"Kennen","title":"the Heart of the Tempest","blurb":"More than just the lightning-quick enforcer of Ionian balance, Kennen is the only yordle member of the Kinkou. Despite his small, furry stature, he is eager to take on any threat with a whirling storm of shuriken and boundless enthusiasm. Alongside his...","info":{"attack":6,"defense":4,"magic":7,"difficulty":4},"image":{"full":"Kennen.png","sprite":"champion2.png","group":"champion","x":144,"y":0,"w":48,"h":48},"tags":["Mage"],"partype":"Energy","stats":{"hp":580,"hpperlevel":98,"mp":200,"mpperlevel":0,"movespeed":335,"armor":29,"armorperlevel":4.95,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.65,"mpregen":50,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":48,"attackdamageperlevel":0,"attackspeedperlevel":3.4,"attackspeed":0.625}},"Khazix":{"version":"16.10.1","id":"Khazix","key":"121","name":"Kha'Zix","title":"the Voidreaver","blurb":"The Void grows, and the Void adapts—in none of its myriad spawn are these truths more apparent than Kha'Zix. Evolution drives the core of this mutating horror, born to survive and to slay the strong. Where it struggles to do so, it grows new, more...","info":{"attack":9,"defense":4,"magic":3,"difficulty":6},"image":{"full":"Khazix.png","sprite":"champion2.png","group":"champion","x":192,"y":0,"w":48,"h":48},"tags":["Assassin"],"partype":"Mana","stats":{"hp":643,"hpperlevel":99,"mp":327,"mpperlevel":40,"movespeed":345,"armor":32,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":7.5,"hpregenperlevel":0.75,"mpregen":7.59,"mpregenperlevel":0.5,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":2.7,"attackspeed":0.668}},"Kindred":{"version":"16.10.1","id":"Kindred","key":"203","name":"Kindred","title":"The Eternal Hunters","blurb":"Separate, but never parted, Kindred represents the twin essences of death. Lamb's bow offers a swift release from the mortal realm for those who accept their fate. Wolf hunts down those who run from their end, delivering violent finality within his...","info":{"attack":8,"defense":2,"magic":2,"difficulty":4},"image":{"full":"Kindred.png","sprite":"champion2.png","group":"champion","x":240,"y":0,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":595,"hpperlevel":104,"mp":300,"mpperlevel":35,"movespeed":325,"armor":29,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":7,"hpregenperlevel":0.55,"mpregen":7,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":65,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.625}},"Kled":{"version":"16.10.1","id":"Kled","key":"240","name":"Kled","title":"the Cantankerous Cavalier","blurb":"A warrior as fearless as he is ornery, the yordle Kled embodies the furious bravado of Noxus. He is an icon beloved by the empire's soldiers, distrusted by its officers, and loathed by the nobility. Many claim Kled has fought in every campaign the...","info":{"attack":8,"defense":2,"magic":2,"difficulty":7},"image":{"full":"Kled.png","sprite":"champion2.png","group":"champion","x":288,"y":0,"w":48,"h":48},"tags":["Fighter"],"partype":"Courage","stats":{"hp":410,"hpperlevel":84,"mp":100,"mpperlevel":0,"movespeed":345,"armor":35,"armorperlevel":5.2,"spellblock":28,"spellblockperlevel":2.05,"attackrange":125,"hpregen":6,"hpregenperlevel":0.75,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":65,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.625}},"KogMaw":{"version":"16.10.1","id":"KogMaw","key":"96","name":"Kog'Maw","title":"the Mouth of the Abyss","blurb":"Belched forth from a rotting Void incursion deep in the wastelands of Icathia, Kog'Maw is an inquisitive yet putrid creature with a caustic, gaping mouth. This particular Void-spawn needs to gnaw and drool on anything within reach to truly understand it...","info":{"attack":8,"defense":2,"magic":5,"difficulty":6},"image":{"full":"KogMaw.png","sprite":"champion2.png","group":"champion","x":336,"y":0,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":635,"hpperlevel":99,"mp":325,"mpperlevel":40,"movespeed":330,"armor":24,"armorperlevel":4.45,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":3.75,"hpregenperlevel":0.55,"mpregen":8.75,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":2.65,"attackspeed":0.665}},"KSante":{"version":"16.10.1","id":"KSante","key":"897","name":"K'Sante","title":"the Pride of Nazumah","blurb":"Defiant and courageous, K'Sante battles colossal beasts and ruthless Ascended to protect his home of Nazumah, a coveted oasis amid the sands of Shurima. But after a falling-out with his former partner, K'Sante realizes that in order to become a warrior...","info":{"attack":8,"defense":8,"magic":7,"difficulty":9},"image":{"full":"KSante.png","sprite":"champion2.png","group":"champion","x":384,"y":0,"w":48,"h":48},"tags":["Tank","Fighter"],"partype":"Mana","stats":{"hp":625,"hpperlevel":120,"mp":320,"mpperlevel":60,"movespeed":330,"armor":36,"armorperlevel":5.2,"spellblock":30,"spellblockperlevel":2.1,"attackrange":150,"hpregen":9.5,"hpregenperlevel":1,"mpregen":7,"mpregenperlevel":1,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.688}},"Leblanc":{"version":"16.10.1","id":"Leblanc","key":"7","name":"LeBlanc","title":"the Deceiver","blurb":"Mysterious even to other members of the Black Rose cabal, LeBlanc is but one of many names for a pale woman who has manipulated people and events since the earliest days of Noxus. Using her magic to mirror herself, the sorceress can appear to anyone...","info":{"attack":1,"defense":4,"magic":10,"difficulty":9},"image":{"full":"Leblanc.png","sprite":"champion2.png","group":"champion","x":432,"y":0,"w":48,"h":48},"tags":["Assassin","Mage"],"partype":"Mana","stats":{"hp":598,"hpperlevel":108,"mp":400,"mpperlevel":25,"movespeed":340,"armor":22,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":7.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2.35,"attackspeed":0.658}},"LeeSin":{"version":"16.10.1","id":"LeeSin","key":"64","name":"Lee Sin","title":"the Blind Monk","blurb":"A master of Ionia's ancient martial arts, Lee Sin is a principled fighter who channels the essence of the dragon spirit to face any challenge. Though he lost his sight many years ago, the warrior-monk has devoted his life to protecting his homeland...","info":{"attack":8,"defense":5,"magic":3,"difficulty":6},"image":{"full":"LeeSin.png","sprite":"champion2.png","group":"champion","x":0,"y":48,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Energy","stats":{"hp":645,"hpperlevel":108,"mp":200,"mpperlevel":0,"movespeed":345,"armor":36,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":7.5,"hpregenperlevel":0.7,"mpregen":50,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":66,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.651}},"Leona":{"version":"16.10.1","id":"Leona","key":"89","name":"Leona","title":"the Radiant Dawn","blurb":"Imbued with the fire of the sun, Leona is a holy warrior of the Solari who defends Mount Targon with her Zenith Blade and the Shield of Daybreak. Her skin shimmers with starfire while her eyes burn with the power of the celestial Aspect within her...","info":{"attack":4,"defense":8,"magic":3,"difficulty":4},"image":{"full":"Leona.png","sprite":"champion2.png","group":"champion","x":48,"y":48,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":646,"hpperlevel":101,"mp":302,"mpperlevel":40,"movespeed":335,"armor":43,"armorperlevel":4.8,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.85,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":2.9,"attackspeed":0.625}},"Lillia":{"version":"16.10.1","id":"Lillia","key":"876","name":"Lillia","title":"the Bashful Bloom","blurb":"Intensely shy, the fae fawn Lillia skittishly wanders Ionia's forests. Hiding just out of sight of mortals—whose mysterious natures have long captivated, but intimidated, her—Lillia hopes to discover why their dreams no longer reach the ancient Dreaming...","info":{"attack":0,"defense":2,"magic":10,"difficulty":8},"image":{"full":"Lillia.png","sprite":"champion2.png","group":"champion","x":96,"y":48,"w":48,"h":48},"tags":["Fighter","Mage"],"partype":"Mana","stats":{"hp":605,"hpperlevel":105,"mp":410,"mpperlevel":50,"movespeed":330,"armor":22,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":1.55,"attackrange":325,"hpregen":2.5,"hpregenperlevel":0.55,"mpregen":11.5,"mpregenperlevel":0.95,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":2.7,"attackspeed":0.625}},"Lissandra":{"version":"16.10.1","id":"Lissandra","key":"127","name":"Lissandra","title":"the Ice Witch","blurb":"Lissandra's magic twists the pure power of ice into something dark and terrible. With the force of her black ice, she does more than freeze—she impales and crushes those who oppose her. To the terrified denizens of the north, she is known only as ''The...","info":{"attack":2,"defense":5,"magic":8,"difficulty":6},"image":{"full":"Lissandra.png","sprite":"champion2.png","group":"champion","x":144,"y":48,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":620,"hpperlevel":110,"mp":475,"mpperlevel":30,"movespeed":325,"armor":22,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":7,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":1.5,"attackspeed":0.656}},"Lucian":{"version":"16.10.1","id":"Lucian","key":"236","name":"Lucian","title":"the Purifier","blurb":"Lucian, a Sentinel of Light, is a grim hunter of wraiths and specters, pursuing them relentlessly and annihilating them with his twin relic pistols. After the specter Thresh slew his wife, Lucian embarked on the path of vengeance—but even with her...","info":{"attack":8,"defense":5,"magic":3,"difficulty":6},"image":{"full":"Lucian.png","sprite":"champion2.png","group":"champion","x":192,"y":48,"w":48,"h":48},"tags":["Marksman","Assassin"],"partype":"Mana","stats":{"hp":641,"hpperlevel":100,"mp":320,"mpperlevel":43,"movespeed":335,"armor":28,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":3.75,"hpregenperlevel":0.65,"mpregen":7,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.638}},"Lulu":{"version":"16.10.1","id":"Lulu","key":"117","name":"Lulu","title":"the Fae Sorceress","blurb":"The yordle mage Lulu is known for conjuring dreamlike illusions and fanciful creatures as she roams Runeterra with her fairy companion Pix. Lulu shapes reality on a whim, warping the fabric of the world, and what she views as the constraints of this...","info":{"attack":4,"defense":5,"magic":7,"difficulty":5},"image":{"full":"Lulu.png","sprite":"champion2.png","group":"champion","x":240,"y":48,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":565,"hpperlevel":92,"mp":350,"mpperlevel":55,"movespeed":330,"armor":26,"armorperlevel":4.6,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":6,"hpregenperlevel":0.6,"mpregen":11,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":47,"attackdamageperlevel":0,"attackspeedperlevel":2.25,"attackspeed":0.625}},"Lux":{"version":"16.10.1","id":"Lux","key":"99","name":"Lux","title":"the Lady of Luminosity","blurb":"Luxanna Crownguard hails from Demacia, an insular realm where magical abilities are viewed with fear and suspicion. Able to bend light to her will, she grew up dreading discovery and exile, and was forced to keep her power secret, in order to preserve...","info":{"attack":2,"defense":4,"magic":9,"difficulty":5},"image":{"full":"Lux.png","sprite":"champion2.png","group":"champion","x":288,"y":48,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":580,"hpperlevel":99,"mp":440,"mpperlevel":23.5,"movespeed":330,"armor":21,"armorperlevel":5.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":9,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":54,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.669}},"Malphite":{"version":"16.10.1","id":"Malphite","key":"54","name":"Malphite","title":"Shard of the Monolith","blurb":"A massive creature of living stone, Malphite struggles to impose blessed order on a chaotic world. Birthed as a servitor-shard to an otherworldly obelisk known as the Monolith, he used his tremendous elemental strength to maintain and protect his...","info":{"attack":5,"defense":9,"magic":7,"difficulty":2},"image":{"full":"Malphite.png","sprite":"champion2.png","group":"champion","x":336,"y":48,"w":48,"h":48},"tags":["Tank","Mage"],"partype":"Mana","stats":{"hp":665,"hpperlevel":104,"mp":280,"mpperlevel":60,"movespeed":335,"armor":40,"armorperlevel":4.95,"spellblock":28,"spellblockperlevel":2.05,"attackrange":125,"hpregen":7,"hpregenperlevel":0.55,"mpregen":7.3,"mpregenperlevel":0.55,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":3.4,"attackspeed":0.736}},"Malzahar":{"version":"16.10.1","id":"Malzahar","key":"90","name":"Malzahar","title":"the Prophet of the Void","blurb":"A zealous seer dedicated to the unification of all life, Malzahar truly believes the newly emergent Void to be the path to Runeterra's salvation. In the desert wastes of Shurima, he followed the voices that whispered in his mind, all the way to ancient...","info":{"attack":2,"defense":2,"magic":9,"difficulty":6},"image":{"full":"Malzahar.png","sprite":"champion2.png","group":"champion","x":384,"y":48,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":580,"hpperlevel":101,"mp":375,"mpperlevel":28,"movespeed":335,"armor":18,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":6,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":1.5,"attackspeed":0.625}},"Maokai":{"version":"16.10.1","id":"Maokai","key":"57","name":"Maokai","title":"the Twisted Treant","blurb":"Maokai is a rageful, towering treant who fights the unnatural horrors of the Shadow Isles. He was twisted into a force of vengeance after a magical cataclysm destroyed his home, surviving undeath only through the Waters of Life infused within his...","info":{"attack":3,"defense":8,"magic":6,"difficulty":3},"image":{"full":"Maokai.png","sprite":"champion2.png","group":"champion","x":432,"y":48,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":665,"hpperlevel":109,"mp":375,"mpperlevel":43,"movespeed":335,"armor":35,"armorperlevel":5.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":5,"hpregenperlevel":0.75,"mpregen":6,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":2.125,"attackspeed":0.8}},"MasterYi":{"version":"16.10.1","id":"MasterYi","key":"11","name":"Master Yi","title":"the Wuju Bladesman","blurb":"Master Yi has tempered his body and sharpened his mind, so that thought and action have become almost as one. Though he chooses to enter into violence only as a last resort, the grace and speed of his blade ensures resolution is always swift. As one of...","info":{"attack":10,"defense":4,"magic":2,"difficulty":4},"image":{"full":"MasterYi.png","sprite":"champion2.png","group":"champion","x":0,"y":96,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":640,"hpperlevel":105,"mp":251,"mpperlevel":42,"movespeed":355,"armor":33,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":7.5,"hpregenperlevel":0.65,"mpregen":7.25,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":65,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.679}},"Mel":{"version":"16.10.1","id":"Mel","key":"800","name":"Mel","title":"the Soul's Reflection","blurb":"Mel Medarda is the presumed heir of the Medarda family, once one of the most powerful in Noxus. In appearance she is a graceful aristocrat, but beneath the surface lies a skilled politician who makes it her business to know everything about everyone she...","info":{"attack":2,"defense":4,"magic":9,"difficulty":5},"image":{"full":"Mel.png","sprite":"champion2.png","group":"champion","x":48,"y":96,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":630,"hpperlevel":99,"mp":480,"mpperlevel":28,"movespeed":330,"armor":21,"armorperlevel":5.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":6,"hpregenperlevel":0.55,"mpregen":9,"mpregenperlevel":0.9,"crit":0,"critperlevel":0,"attackdamage":54,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.625}},"Milio":{"version":"16.10.1","id":"Milio","key":"902","name":"Milio","title":"The Gentle Flame","blurb":"Milio is a warmhearted boy from Ixtal who has, despite his young age, mastered the fire axiom and discovered something new: soothing fire. With this newfound power, Milio plans to help his family escape their exile by joining the Yun Tal—just like his...","info":{"attack":2,"defense":4,"magic":8,"difficulty":5},"image":{"full":"Milio.png","sprite":"champion2.png","group":"champion","x":96,"y":96,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":560,"hpperlevel":88,"mp":365,"mpperlevel":43,"movespeed":330,"armor":26,"armorperlevel":4.6,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":5,"hpregenperlevel":0.5,"mpregen":11.5,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":48,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.625}},"MissFortune":{"version":"16.10.1","id":"MissFortune","key":"21","name":"Miss Fortune","title":"the Bounty Hunter","blurb":"A Bilgewater captain famed for her looks but feared for her ruthlessness, Sarah Fortune paints a stark figure among the hardened criminals of the port city. As a child, she witnessed the reaver king Gangplank murder her family—an act she brutally...","info":{"attack":8,"defense":2,"magic":5,"difficulty":1},"image":{"full":"MissFortune.png","sprite":"champion2.png","group":"champion","x":144,"y":96,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":625,"hpperlevel":100,"mp":300,"mpperlevel":40,"movespeed":325,"armor":25,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":3.75,"hpregenperlevel":0.65,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.656}},"MonkeyKing":{"version":"16.10.1","id":"MonkeyKing","key":"62","name":"Wukong","title":"the Monkey King","blurb":"Wukong is a vastayan trickster who uses his strength, agility, and intelligence to confuse his opponents and gain the upper hand. After finding a lifelong friend in the warrior known as Master Yi, Wukong became the last student of the ancient martial...","info":{"attack":8,"defense":5,"magic":2,"difficulty":3},"image":{"full":"MonkeyKing.png","sprite":"champion2.png","group":"champion","x":192,"y":96,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":610,"hpperlevel":99,"mp":330,"mpperlevel":65,"movespeed":340,"armor":31,"armorperlevel":4.7,"spellblock":28,"spellblockperlevel":2.05,"attackrange":175,"hpregen":3.5,"hpregenperlevel":0.65,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":66,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.69}},"Mordekaiser":{"version":"16.10.1","id":"Mordekaiser","key":"82","name":"Mordekaiser","title":"the Iron Revenant","blurb":"Twice slain and thrice born, Mordekaiser is a brutal warlord from a foregone epoch who uses his necromantic sorcery to bind souls into an eternity of servitude. Few now remain who remember his earlier conquests, or know the true extent of his powers—but...","info":{"attack":4,"defense":6,"magic":7,"difficulty":4},"image":{"full":"Mordekaiser.png","sprite":"champion2.png","group":"champion","x":240,"y":96,"w":48,"h":48},"tags":["Fighter","Mage"],"partype":"Shield","stats":{"hp":645,"hpperlevel":104,"mp":100,"mpperlevel":0,"movespeed":335,"armor":37,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":5,"hpregenperlevel":0.75,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":1,"attackspeed":0.625}},"Morgana":{"version":"16.10.1","id":"Morgana","key":"25","name":"Morgana","title":"the Fallen","blurb":"Conflicted between her celestial and mortal natures, Morgana bound her wings to embrace humanity, and inflicts her pain and bitterness upon the dishonest and the corrupt. She rejects laws and traditions she believes are unjust, and fights for truth from...","info":{"attack":1,"defense":6,"magic":8,"difficulty":1},"image":{"full":"Morgana.png","sprite":"champion2.png","group":"champion","x":288,"y":96,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":630,"hpperlevel":104,"mp":340,"mpperlevel":60,"movespeed":335,"armor":25,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":450,"hpregen":5.5,"hpregenperlevel":0.4,"mpregen":11,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":56,"attackdamageperlevel":0,"attackspeedperlevel":1.53,"attackspeed":0.625}},"Naafiri":{"version":"16.10.1","id":"Naafiri","key":"950","name":"Naafiri","title":"the Hound of a Hundred Bites","blurb":"Across the sands of Shurima, a chorus of howls rings out. It is the call of the dune hounds, voracious predators who form packs and compete for the right to hunt in these barren lands. Among them, one pack stands above all, for they are driven not only...","info":{"attack":9,"defense":5,"magic":0,"difficulty":2},"image":{"full":"Naafiri.png","sprite":"champion2.png","group":"champion","x":336,"y":96,"w":48,"h":48},"tags":["Assassin","Fighter"],"partype":"Mana","stats":{"hp":610,"hpperlevel":105,"mp":400,"mpperlevel":55,"movespeed":340,"armor":28,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":6.25,"hpregenperlevel":0.6,"mpregen":7.5,"mpregenperlevel":1,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2.1,"attackspeed":0.663}},"Nami":{"version":"16.10.1","id":"Nami","key":"267","name":"Nami","title":"the Tidecaller","blurb":"A headstrong young vastaya of the seas, Nami was the first of the Marai tribe to leave the waves and venture onto dry land, when their ancient accord with the Targonians was broken. With no other option, she took it upon herself to complete the sacred...","info":{"attack":4,"defense":3,"magic":7,"difficulty":5},"image":{"full":"Nami.png","sprite":"champion2.png","group":"champion","x":384,"y":96,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":560,"hpperlevel":88,"mp":365,"mpperlevel":43,"movespeed":335,"armor":29,"armorperlevel":5.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":11.5,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":54,"attackdamageperlevel":0,"attackspeedperlevel":2.61,"attackspeed":0.644}},"Nasus":{"version":"16.10.1","id":"Nasus","key":"75","name":"Nasus","title":"the Curator of the Sands","blurb":"Nasus is an imposing, jackal-headed Ascended being from ancient Shurima, a heroic figure regarded as a demigod by the people of the desert. Fiercely intelligent, he was a guardian of knowledge and peerless strategist whose wisdom guided the ancient...","info":{"attack":7,"defense":5,"magic":6,"difficulty":6},"image":{"full":"Nasus.png","sprite":"champion2.png","group":"champion","x":432,"y":96,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":650,"hpperlevel":104,"mp":326,"mpperlevel":62,"movespeed":350,"armor":34,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":9,"hpregenperlevel":0.9,"mpregen":7.45,"mpregenperlevel":0.5,"crit":0,"critperlevel":0,"attackdamage":67,"attackdamageperlevel":0,"attackspeedperlevel":3.48,"attackspeed":0.638}},"Nautilus":{"version":"16.10.1","id":"Nautilus","key":"111","name":"Nautilus","title":"the Titan of the Depths","blurb":"A lonely legend as old as the first piers sunk in Bilgewater, the armored goliath known as Nautilus roams the dark waters off the coast of the Blue Flame Isles. Driven by a forgotten betrayal, he strikes without warning, swinging his enormous anchor to...","info":{"attack":4,"defense":6,"magic":6,"difficulty":6},"image":{"full":"Nautilus.png","sprite":"champion3.png","group":"champion","x":0,"y":0,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":646,"hpperlevel":100,"mp":400,"mpperlevel":47,"movespeed":325,"armor":39,"armorperlevel":4.95,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":8.5,"hpregenperlevel":0.55,"mpregen":8.65,"mpregenperlevel":0.5,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":1,"attackspeed":0.706}},"Neeko":{"version":"16.10.1","id":"Neeko","key":"518","name":"Neeko","title":"the Curious Chameleon","blurb":"Hailing from a long lost tribe of vastaya, Neeko can blend into any crowd by borrowing the appearances of others, even absorbing something of their emotional state to tell friend from foe in an instant. No one is ever sure where—or who—Neeko might be...","info":{"attack":1,"defense":1,"magic":9,"difficulty":5},"image":{"full":"Neeko.png","sprite":"champion3.png","group":"champion","x":48,"y":0,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":610,"hpperlevel":104,"mp":450,"mpperlevel":30,"movespeed":340,"armor":21,"armorperlevel":5.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":7.5,"hpregenperlevel":0.75,"mpregen":7,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":48,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.625}},"Nidalee":{"version":"16.10.1","id":"Nidalee","key":"76","name":"Nidalee","title":"the Bestial Huntress","blurb":"Raised in the deepest jungle, Nidalee is a master tracker who can shapeshift into a ferocious cougar at will. Neither wholly woman nor beast, she viciously defends her territory from any and all trespassers, with carefully placed traps and deft spear...","info":{"attack":5,"defense":4,"magic":7,"difficulty":8},"image":{"full":"Nidalee.png","sprite":"champion3.png","group":"champion","x":96,"y":0,"w":48,"h":48},"tags":["Assassin","Mage"],"partype":"Mana","stats":{"hp":610,"hpperlevel":109,"mp":295,"mpperlevel":45,"movespeed":335,"armor":32,"armorperlevel":5,"spellblock":30,"spellblockperlevel":1.45,"attackrange":525,"hpregen":6,"hpregenperlevel":0.6,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":3.22,"attackspeed":0.638}},"Nilah":{"version":"16.10.1","id":"Nilah","key":"895","name":"Nilah","title":"the Joy Unbound","blurb":"Nilah is an ascetic warrior from a distant land, seeking the world's deadliest, most titanic opponents so that she might challenge and destroy them. Having won her power through an encounter with the long-imprisoned demon of joy, she has no emotions...","info":{"attack":8,"defense":4,"magic":4,"difficulty":10},"image":{"full":"Nilah.png","sprite":"champion3.png","group":"champion","x":144,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":570,"hpperlevel":101,"mp":350,"mpperlevel":35,"movespeed":340,"armor":27,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":225,"hpregen":6,"hpregenperlevel":0.9,"mpregen":8.2,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":1.25,"attackspeed":0.697}},"Nocturne":{"version":"16.10.1","id":"Nocturne","key":"56","name":"Nocturne","title":"the Eternal Nightmare","blurb":"A demonic amalgamation drawn from the nightmares that haunt every sentient mind, the thing known as Nocturne has become a primordial force of pure evil. It is liquidly chaotic in aspect, a faceless shadow with cold eyes and armed with wicked-looking...","info":{"attack":9,"defense":5,"magic":2,"difficulty":4},"image":{"full":"Nocturne.png","sprite":"champion3.png","group":"champion","x":192,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":655,"hpperlevel":109,"mp":275,"mpperlevel":35,"movespeed":345,"armor":38,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":1.55,"attackrange":125,"hpregen":7,"hpregenperlevel":0.75,"mpregen":7,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":2.7,"attackspeed":0.721}},"Nunu":{"version":"16.10.1","id":"Nunu","key":"20","name":"Nunu & Willump","title":"the Boy and His Yeti","blurb":"Once upon a time, there was a boy who wanted to prove he was a hero by slaying a fearsome monster—only to discover that the beast, a lonely and magical yeti, merely needed a friend. Bound together by ancient power and a shared love of snowballs, Nunu...","info":{"attack":4,"defense":6,"magic":7,"difficulty":4},"image":{"full":"Nunu.png","sprite":"champion3.png","group":"champion","x":240,"y":0,"w":48,"h":48},"tags":["Tank","Mage"],"partype":"Mana","stats":{"hp":610,"hpperlevel":90,"mp":280,"mpperlevel":42,"movespeed":345,"armor":29,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":5,"hpregenperlevel":0.8,"mpregen":7,"mpregenperlevel":0.5,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":2.25,"attackspeed":0.625}},"Olaf":{"version":"16.10.1","id":"Olaf","key":"2","name":"Olaf","title":"the Berserker","blurb":"An unstoppable force of destruction, the axe-wielding Olaf wants nothing but to die in glorious combat. Hailing from the brutal Freljordian peninsula of Lokfar, he once received a prophecy foretelling his peaceful passing—a coward's fate, and a great...","info":{"attack":9,"defense":5,"magic":3,"difficulty":3},"image":{"full":"Olaf.png","sprite":"champion3.png","group":"champion","x":288,"y":0,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":645,"hpperlevel":119,"mp":316,"mpperlevel":50,"movespeed":350,"armor":35,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":6.5,"hpregenperlevel":0.6,"mpregen":7.5,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":68,"attackdamageperlevel":0,"attackspeedperlevel":2.7,"attackspeed":0.72}},"Orianna":{"version":"16.10.1","id":"Orianna","key":"61","name":"Orianna","title":"the Lady of Clockwork","blurb":"Once a curious girl of flesh and blood, Orianna is now a technological marvel comprised entirely of clockwork. She became gravely ill after an accident in the lower districts of Zaun, and her failing body had to be replaced with exquisite artifice...","info":{"attack":4,"defense":3,"magic":9,"difficulty":7},"image":{"full":"Orianna.png","sprite":"champion3.png","group":"champion","x":336,"y":0,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":585,"hpperlevel":110,"mp":418,"mpperlevel":25,"movespeed":325,"armor":20,"armorperlevel":4.2,"spellblock":26,"spellblockperlevel":1.3,"attackrange":525,"hpregen":7,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":44,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.658}},"Ornn":{"version":"16.10.1","id":"Ornn","key":"516","name":"Ornn","title":"The Fire below the Mountain","blurb":"Ornn is the Freljordian spirit of forging and craftsmanship. He works in the solitude of a massive smithy, hammered out from the lava caverns beneath the volcano Hearth-Home. There he stokes bubbling cauldrons of molten rock to purify ores and fashion...","info":{"attack":5,"defense":9,"magic":3,"difficulty":5},"image":{"full":"Ornn.png","sprite":"champion3.png","group":"champion","x":384,"y":0,"w":48,"h":48},"tags":["Tank"],"partype":"Mana","stats":{"hp":660,"hpperlevel":109,"mp":341,"mpperlevel":65,"movespeed":335,"armor":33,"armorperlevel":5.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":9,"hpregenperlevel":0.9,"mpregen":8,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":69,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.625}},"Pantheon":{"version":"16.10.1","id":"Pantheon","key":"80","name":"Pantheon","title":"the Unbreakable Spear","blurb":"Once an unwilling host to the Aspect of War, Atreus survived when the celestial power within him was slain, refusing to succumb to a blow that tore stars from the heavens. In time, he learned to embrace the power of his own mortality, and the stubborn...","info":{"attack":9,"defense":4,"magic":3,"difficulty":4},"image":{"full":"Pantheon.png","sprite":"champion3.png","group":"champion","x":432,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":650,"hpperlevel":109,"mp":317,"mpperlevel":31,"movespeed":345,"armor":40,"armorperlevel":4.95,"spellblock":28,"spellblockperlevel":2.05,"attackrange":175,"hpregen":6,"hpregenperlevel":0.65,"mpregen":7.35,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":2.95,"attackspeed":0.658}},"Poppy":{"version":"16.10.1","id":"Poppy","key":"78","name":"Poppy","title":"Keeper of the Hammer","blurb":"Runeterra has no shortage of valiant champions, but few are as tenacious as Poppy. Bearing the legendary hammer of Orlon, a weapon twice her size, this determined yordle has spent untold years searching in secret for the fabled “Hero of Demacia,” said...","info":{"attack":6,"defense":7,"magic":2,"difficulty":6},"image":{"full":"Poppy.png","sprite":"champion3.png","group":"champion","x":0,"y":48,"w":48,"h":48},"tags":["Tank","Fighter"],"partype":"Mana","stats":{"hp":610,"hpperlevel":110,"mp":280,"mpperlevel":40,"movespeed":345,"armor":35,"armorperlevel":5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8,"hpregenperlevel":0.8,"mpregen":7,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.658}},"Pyke":{"version":"16.10.1","id":"Pyke","key":"555","name":"Pyke","title":"the Bloodharbor Ripper","blurb":"A renowned harpooner from the slaughter docks of Bilgewater, Pyke should have met his death in the belly of a gigantic jaull-fish… and yet, he returned. Now, stalking the dank alleys and backways of his former hometown, he uses his new supernatural...","info":{"attack":9,"defense":3,"magic":1,"difficulty":7},"image":{"full":"Pyke.png","sprite":"champion3.png","group":"champion","x":48,"y":48,"w":48,"h":48},"tags":["Support","Assassin"],"partype":"Mana","stats":{"hp":670,"hpperlevel":110,"mp":415,"mpperlevel":50,"movespeed":330,"armor":37,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":7,"hpregenperlevel":0.5,"mpregen":8,"mpregenperlevel":1,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.667}},"Qiyana":{"version":"16.10.1","id":"Qiyana","key":"246","name":"Qiyana","title":"Empress of the Elements","blurb":"In the jungle city of Ixaocan, Qiyana plots her own ruthless path to the high seat of the Yun Tal. Last in line to succeed her parents, she faces those who stand in her way with brash confidence and unprecedented mastery over elemental magic. With the...","info":{"attack":0,"defense":2,"magic":4,"difficulty":8},"image":{"full":"Qiyana.png","sprite":"champion3.png","group":"champion","x":96,"y":48,"w":48,"h":48},"tags":["Assassin"],"partype":"Mana","stats":{"hp":590,"hpperlevel":115,"mp":375,"mpperlevel":60,"movespeed":335,"armor":31,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":8,"hpregenperlevel":0.9,"mpregen":8,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":2.1,"attackspeed":0.688}},"Quinn":{"version":"16.10.1","id":"Quinn","key":"133","name":"Quinn","title":"Demacia's Wings","blurb":"Quinn is an elite ranger-knight of Demacia, who undertakes dangerous missions deep in enemy territory. She and her legendary eagle, Valor, share an unbreakable bond, and their foes are often slain before they realize they are fighting not one, but two...","info":{"attack":9,"defense":4,"magic":2,"difficulty":5},"image":{"full":"Quinn.png","sprite":"champion3.png","group":"champion","x":144,"y":48,"w":48,"h":48},"tags":["Marksman","Assassin"],"partype":"Mana","stats":{"hp":565,"hpperlevel":107,"mp":269,"mpperlevel":35,"movespeed":330,"armor":28,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":7,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":3.1,"attackspeed":0.668}},"Rakan":{"version":"16.10.1","id":"Rakan","key":"497","name":"Rakan","title":"The Charmer","blurb":"As mercurial as he is charming, Rakan is an infamous vastayan troublemaker and the greatest battle-dancer in Lhotlan tribal history. To the humans of the Ionian highlands, his name has long been synonymous with wild festivals, uncontrollable parties...","info":{"attack":2,"defense":4,"magic":8,"difficulty":5},"image":{"full":"Rakan.png","sprite":"champion3.png","group":"champion","x":192,"y":48,"w":48,"h":48},"tags":["Support"],"partype":"Mana","stats":{"hp":610,"hpperlevel":99,"mp":315,"mpperlevel":50,"movespeed":335,"armor":30,"armorperlevel":4.9,"spellblock":32,"spellblockperlevel":2.05,"attackrange":300,"hpregen":5,"hpregenperlevel":0.5,"mpregen":8.75,"mpregenperlevel":0.5,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.635}},"Rammus":{"version":"16.10.1","id":"Rammus","key":"33","name":"Rammus","title":"the Armordillo","blurb":"Idolized by many, dismissed by some, mystifying to all, the curious being Rammus is an enigma. Protected by a spiked shell, he inspires increasingly disparate theories on his origin wherever he goes—from demigod, to sacred oracle, to a mere beast...","info":{"attack":4,"defense":10,"magic":5,"difficulty":5},"image":{"full":"Rammus.png","sprite":"champion3.png","group":"champion","x":240,"y":48,"w":48,"h":48},"tags":["Tank"],"partype":"Mana","stats":{"hp":645,"hpperlevel":100,"mp":310,"mpperlevel":33,"movespeed":335,"armor":35,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8,"hpregenperlevel":0.55,"mpregen":7.85,"mpregenperlevel":0.5,"crit":0,"critperlevel":0,"attackdamage":65,"attackdamageperlevel":0,"attackspeedperlevel":2.215,"attackspeed":0.7}},"RekSai":{"version":"16.10.1","id":"RekSai","key":"421","name":"Rek'Sai","title":"the Void Burrower","blurb":"An apex predator, Rek'Sai is a merciless Void-spawn that tunnels beneath the ground to ambush and devour unsuspecting prey. Her insatiable hunger has laid waste to entire regions of the once-great empire of Shurima—merchants, traders, even armed...","info":{"attack":8,"defense":5,"magic":2,"difficulty":3},"image":{"full":"RekSai.png","sprite":"champion3.png","group":"champion","x":288,"y":48,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Rage","stats":{"hp":600,"hpperlevel":99,"mp":100,"mpperlevel":0,"movespeed":340,"armor":35,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":2.5,"hpregenperlevel":0.5,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.667}},"Rell":{"version":"16.10.1","id":"Rell","key":"526","name":"Rell","title":"the Iron Maiden","blurb":"The product of brutal experimentation at the hands of the Black Rose, Rell is a defiant, living weapon determined to topple Noxus. Her childhood was one of misery and horror, enduring unspeakable procedures to perfect and weaponize her magical control...","info":{"attack":0,"defense":0,"magic":0,"difficulty":0},"image":{"full":"Rell.png","sprite":"champion3.png","group":"champion","x":336,"y":48,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":620,"hpperlevel":104,"mp":320,"mpperlevel":40,"movespeed":315,"armor":30,"armorperlevel":4,"spellblock":28,"spellblockperlevel":1.8,"attackrange":175,"hpregen":7.5,"hpregenperlevel":0.85,"mpregen":7,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.625}},"Renata":{"version":"16.10.1","id":"Renata","key":"888","name":"Renata Glasc","title":"the Chem-Baroness","blurb":"Renata Glasc rose from the ashes of her childhood home with nothing but her name and her parents' alchemical research. In the decades since, she has become Zaun's wealthiest chem-baron, a business magnate who built her power by tying everyone's...","info":{"attack":2,"defense":6,"magic":9,"difficulty":8},"image":{"full":"Renata.png","sprite":"champion3.png","group":"champion","x":384,"y":48,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":545,"hpperlevel":94,"mp":350,"mpperlevel":50,"movespeed":330,"armor":27,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":11.5,"mpregenperlevel":0.5,"crit":0,"critperlevel":0,"attackdamage":49,"attackdamageperlevel":0,"attackspeedperlevel":2.11,"attackspeed":0.625}},"Renekton":{"version":"16.10.1","id":"Renekton","key":"58","name":"Renekton","title":"the Butcher of the Sands","blurb":"Renekton is a terrifying, rage-fueled Ascended being from the scorched deserts of Shurima. Once, he was his empire's most esteemed warrior, leading the nation's armies to countless victories. However, after the empire's fall, Renekton was entombed...","info":{"attack":8,"defense":5,"magic":2,"difficulty":3},"image":{"full":"Renekton.png","sprite":"champion3.png","group":"champion","x":432,"y":48,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Fury","stats":{"hp":660,"hpperlevel":111,"mp":100,"mpperlevel":0,"movespeed":345,"armor":35,"armorperlevel":5.2,"spellblock":28,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8,"hpregenperlevel":0.75,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":69,"attackdamageperlevel":0,"attackspeedperlevel":2.75,"attackspeed":0.665}},"Rengar":{"version":"16.10.1","id":"Rengar","key":"107","name":"Rengar","title":"the Pridestalker","blurb":"Rengar is a ferocious vastayan trophy hunter who lives for the thrill of tracking down and killing dangerous creatures. He scours the world for the most fearsome beasts he can find, especially seeking any trace of Kha'Zix, the void creature who...","info":{"attack":7,"defense":4,"magic":2,"difficulty":8},"image":{"full":"Rengar.png","sprite":"champion3.png","group":"champion","x":0,"y":96,"w":48,"h":48},"tags":["Assassin","Fighter"],"partype":"Ferocity","stats":{"hp":590,"hpperlevel":104,"mp":4,"mpperlevel":0,"movespeed":345,"armor":34,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":6,"hpregenperlevel":0.5,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":68,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.667}},"Riven":{"version":"16.10.1","id":"Riven","key":"92","name":"Riven","title":"the Exile","blurb":"Once a swordmaster in the warhosts of Noxus, Riven is an expatriate in a land she previously tried to conquer. She rose through the ranks on the strength of her conviction and brutal efficiency, and was rewarded with a legendary runic blade and a...","info":{"attack":8,"defense":5,"magic":1,"difficulty":8},"image":{"full":"Riven.png","sprite":"champion3.png","group":"champion","x":48,"y":96,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"None","stats":{"hp":630,"hpperlevel":100,"mp":0,"mpperlevel":0,"movespeed":340,"armor":33,"armorperlevel":4.4,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.5,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.625}},"Rumble":{"version":"16.10.1","id":"Rumble","key":"68","name":"Rumble","title":"the Mechanized Menace","blurb":"Rumble is a young inventor with a temper. Using nothing more than his own two hands and a heap of scrap, the feisty yordle constructed a colossal mech suit outfitted with an arsenal of electrified harpoons and incendiary rockets. Though others may scoff...","info":{"attack":3,"defense":6,"magic":8,"difficulty":10},"image":{"full":"Rumble.png","sprite":"champion3.png","group":"champion","x":96,"y":96,"w":48,"h":48},"tags":["Fighter","Mage"],"partype":"Heat","stats":{"hp":640,"hpperlevel":105,"mp":150,"mpperlevel":0,"movespeed":345,"armor":36,"armorperlevel":4.7,"spellblock":28,"spellblockperlevel":1.55,"attackrange":125,"hpregen":7,"hpregenperlevel":0.6,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":1.85,"attackspeed":0.644}},"Ryze":{"version":"16.10.1","id":"Ryze","key":"13","name":"Ryze","title":"the Rune Mage","blurb":"Widely considered one of the most adept sorcerers on Runeterra, Ryze is an ancient, hard-bitten archmage with an impossibly heavy burden to bear. Armed with immense arcane power and a boundless constitution, he tirelessly hunts for World Runes—fragments...","info":{"attack":2,"defense":2,"magic":10,"difficulty":7},"image":{"full":"Ryze.png","sprite":"champion3.png","group":"champion","x":144,"y":96,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":645,"hpperlevel":124,"mp":300,"mpperlevel":70,"movespeed":340,"armor":22,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":1.3,"attackrange":550,"hpregen":8,"hpregenperlevel":0.8,"mpregen":8,"mpregenperlevel":1,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":2.11,"attackspeed":0.658}},"Samira":{"version":"16.10.1","id":"Samira","key":"360","name":"Samira","title":"the Desert Rose","blurb":"Samira stares death in the eye with unyielding confidence, seeking thrill wherever she goes. After her Shuriman home was destroyed as a child, Samira found her true calling in Noxus, where she built a reputation as a stylish daredevil taking on...","info":{"attack":8,"defense":5,"magic":3,"difficulty":6},"image":{"full":"Samira.png","sprite":"champion3.png","group":"champion","x":192,"y":96,"w":48,"h":48},"tags":["Marksman","Assassin"],"partype":"Mana","stats":{"hp":630,"hpperlevel":108,"mp":349,"mpperlevel":38,"movespeed":335,"armor":26,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":3.25,"hpregenperlevel":0.55,"mpregen":8.2,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":57,"attackdamageperlevel":0,"attackspeedperlevel":3.3,"attackspeed":0.658}},"Sejuani":{"version":"16.10.1","id":"Sejuani","key":"113","name":"Sejuani","title":"Fury of the North","blurb":"Sejuani is the brutal, unforgiving Iceborn warmother of the Winter's Claw, one of the most feared tribes of the Freljord. Her people's survival is a constant, desperate battle against the elements, forcing them to raid Noxians, Demacians, and Avarosans...","info":{"attack":5,"defense":7,"magic":6,"difficulty":4},"image":{"full":"Sejuani.png","sprite":"champion3.png","group":"champion","x":240,"y":96,"w":48,"h":48},"tags":["Tank"],"partype":"Mana","stats":{"hp":630,"hpperlevel":114,"mp":400,"mpperlevel":40,"movespeed":340,"armor":34,"armorperlevel":5.45,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":8.5,"hpregenperlevel":1,"mpregen":7,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":66,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.688}},"Senna":{"version":"16.10.1","id":"Senna","key":"235","name":"Senna","title":"the Redeemer","blurb":"Cursed from childhood to be haunted by the supernatural Black Mist, Senna joined a sacred order known as the Sentinels of Light, and fiercely fought back—only to be killed, her soul imprisoned in a lantern by the cruel specter Thresh. But refusing to...","info":{"attack":7,"defense":2,"magic":6,"difficulty":7},"image":{"full":"Senna.png","sprite":"champion3.png","group":"champion","x":288,"y":96,"w":48,"h":48},"tags":["Support","Marksman"],"partype":"Mana","stats":{"hp":530,"hpperlevel":89,"mp":350,"mpperlevel":45,"movespeed":330,"armor":25,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":600,"hpregen":3.5,"hpregenperlevel":0.55,"mpregen":11.5,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":50,"attackdamageperlevel":0,"attackspeedperlevel":2.6,"attackspeed":0.625}},"Seraphine":{"version":"16.10.1","id":"Seraphine","key":"147","name":"Seraphine","title":"the Starry-Eyed Songstress","blurb":"Born in Piltover to Zaunite parents, Seraphine can hear the souls of others—the world sings to her, and she sings back. Though these sounds overwhelmed her in her youth, she now draws on them for inspiration, turning the chaos into a symphony. She...","info":{"attack":0,"defense":0,"magic":0,"difficulty":0},"image":{"full":"Seraphine.png","sprite":"champion3.png","group":"champion","x":336,"y":96,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":570,"hpperlevel":95,"mp":360,"mpperlevel":40,"movespeed":330,"armor":26,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":6.5,"hpregenperlevel":0.6,"mpregen":11.5,"mpregenperlevel":0.95,"crit":0,"critperlevel":0,"attackdamage":50,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.669}},"Sett":{"version":"16.10.1","id":"Sett","key":"875","name":"Sett","title":"the Boss","blurb":"A leader of Ionia's growing criminal underworld, Sett rose to prominence in the wake of the war with Noxus. Though he began as a humble challenger in the fighting pits of Navori, he quickly gained notoriety for his savage strength, and his ability to...","info":{"attack":8,"defense":5,"magic":1,"difficulty":2},"image":{"full":"Sett.png","sprite":"champion3.png","group":"champion","x":384,"y":96,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Grit","stats":{"hp":670,"hpperlevel":114,"mp":0,"mpperlevel":0,"movespeed":340,"armor":33,"armorperlevel":4.7,"spellblock":28,"spellblockperlevel":2.05,"attackrange":125,"hpregen":7,"hpregenperlevel":0.5,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":1.75,"attackspeed":0.625}},"Shaco":{"version":"16.10.1","id":"Shaco","key":"35","name":"Shaco","title":"the Demon Jester","blurb":"Crafted long ago as a plaything for a lonely prince, the enchanted marionette Shaco now delights in murder and mayhem. Corrupted by dark magic and the loss of his beloved charge, the once-kind puppet finds pleasure only in the misery of the poor souls...","info":{"attack":8,"defense":4,"magic":6,"difficulty":9},"image":{"full":"Shaco.png","sprite":"champion3.png","group":"champion","x":432,"y":96,"w":48,"h":48},"tags":["Assassin"],"partype":"Mana","stats":{"hp":630,"hpperlevel":99,"mp":297,"mpperlevel":40,"movespeed":345,"armor":30,"armorperlevel":4,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.55,"mpregen":6,"mpregenperlevel":0.35,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.694}},"Shen":{"version":"16.10.1","id":"Shen","key":"98","name":"Shen","title":"the Eye of Twilight","blurb":"Among the secretive, Ionian warriors known as the Kinkou, Shen serves as their leader, the Eye of Twilight. He longs to remain free from the confusion of emotion, prejudice, and ego, and walks the unseen path of dispassionate judgment between the spirit...","info":{"attack":3,"defense":9,"magic":3,"difficulty":4},"image":{"full":"Shen.png","sprite":"champion4.png","group":"champion","x":0,"y":0,"w":48,"h":48},"tags":["Tank"],"partype":"Energy","stats":{"hp":610,"hpperlevel":99,"mp":400,"mpperlevel":0,"movespeed":340,"armor":34,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.75,"mpregen":50,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":64,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.751}},"Shyvana":{"version":"16.10.1","id":"Shyvana","key":"102","name":"Shyvana","title":"the Half-Dragon","blurb":"Shyvana is a fearsome half-dragon warrior. Though she often appears humanoid, she also rules the skies as a dragon, incinerating her foes with fiery breath. Having saved the life of the crown prince Jarvan IV, Shyvana now serves uneasily in his royal...","info":{"attack":8,"defense":6,"magic":3,"difficulty":4},"image":{"full":"Shyvana.png","sprite":"champion4.png","group":"champion","x":48,"y":0,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Fury","stats":{"hp":625,"hpperlevel":95,"mp":100,"mpperlevel":0,"movespeed":350,"armor":35,"armorperlevel":4,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":7,"hpregenperlevel":0.65,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.638}},"Singed":{"version":"16.10.1","id":"Singed","key":"27","name":"Singed","title":"the Mad Chemist","blurb":"Singed is a brilliant alchemist of dubious morality, whose experiments would turn the stomach of even the most cutthroat criminal. Selling his skills to the highest bidder, he cares little for how his noxious concoctions are used, with the ensuing chaos...","info":{"attack":4,"defense":8,"magic":7,"difficulty":5},"image":{"full":"Singed.png","sprite":"champion4.png","group":"champion","x":96,"y":0,"w":48,"h":48},"tags":["Tank","Mage"],"partype":"Mana","stats":{"hp":650,"hpperlevel":96,"mp":330,"mpperlevel":45,"movespeed":345,"armor":34,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":9.5,"hpregenperlevel":0.55,"mpregen":7.5,"mpregenperlevel":0.55,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":1.9,"attackspeed":0.7}},"Sion":{"version":"16.10.1","id":"Sion","key":"14","name":"Sion","title":"The Undead Juggernaut","blurb":"A war hero from a bygone era, Sion was revered in Noxus for choking the life out of a Demacian king with his bare hands—but, denied oblivion, he was resurrected to serve his empire even in death. His indiscriminate slaughter claimed all who stood in his...","info":{"attack":5,"defense":9,"magic":3,"difficulty":5},"image":{"full":"Sion.png","sprite":"champion4.png","group":"champion","x":144,"y":0,"w":48,"h":48},"tags":["Tank","Fighter"],"partype":"Mana","stats":{"hp":655,"hpperlevel":87,"mp":400,"mpperlevel":52,"movespeed":345,"armor":36,"armorperlevel":4.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":9,"hpregenperlevel":0.8,"mpregen":8,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":68,"attackdamageperlevel":0,"attackspeedperlevel":1.3,"attackspeed":0.679}},"Sivir":{"version":"16.10.1","id":"Sivir","key":"15","name":"Sivir","title":"the Battle Mistress","blurb":"Sivir is a renowned fortune hunter and mercenary captain who plies her trade in the deserts of Shurima. Armed with her legendary jeweled crossblade, she has fought and won countless battles for those who can afford her exorbitant price. Known for her...","info":{"attack":9,"defense":3,"magic":1,"difficulty":4},"image":{"full":"Sivir.png","sprite":"champion4.png","group":"champion","x":192,"y":0,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":600,"hpperlevel":104,"mp":340,"mpperlevel":45,"movespeed":335,"armor":30,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":3.25,"hpregenperlevel":0.55,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":1.6,"attackspeed":0.625}},"Skarner":{"version":"16.10.1","id":"Skarner","key":"72","name":"Skarner","title":"the Primordial Sovereign","blurb":"The ancient, colossal brackern Skarner is revered in Ixtal as one of the founding members of its ruling caste, the Yun Tal. Devoted to keeping his nation safe from the rest of the world, Skarner dwells in a chamber beneath Ixaocan where he can hear the...","info":{"attack":7,"defense":8,"magic":5,"difficulty":5},"image":{"full":"Skarner.png","sprite":"champion4.png","group":"champion","x":240,"y":0,"w":48,"h":48},"tags":["Tank","Fighter"],"partype":"Mana","stats":{"hp":630,"hpperlevel":110,"mp":320,"mpperlevel":40,"movespeed":335,"armor":33,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":7.5,"hpregenperlevel":0.75,"mpregen":7.2,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.625}},"Smolder":{"version":"16.10.1","id":"Smolder","key":"901","name":"Smolder","title":"the Fiery Fledgling","blurb":"Hidden amongst the craggy cliffs of the Noxian frontier, under the watchful eyes of his mother, a young dragon is learning what it means to be heir to the Camavoran imperial dragon lineage. Playful and eager to grow up, Smolder looks for any excuse to...","info":{"attack":8,"defense":2,"magic":5,"difficulty":6},"image":{"full":"Smolder.png","sprite":"champion4.png","group":"champion","x":288,"y":0,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":575,"hpperlevel":100,"mp":300,"mpperlevel":40,"movespeed":330,"armor":24,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":3.75,"hpregenperlevel":0.6,"mpregen":8.5,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":4,"attackspeed":0.638}},"Sona":{"version":"16.10.1","id":"Sona","key":"37","name":"Sona","title":"Maven of the Strings","blurb":"Sona is Demacia's foremost virtuoso of the stringed etwahl, speaking only through her graceful chords and vibrant arias. This genteel manner has endeared her to the highborn, though others suspect her spellbinding melodies to actually emanate magic—a...","info":{"attack":5,"defense":2,"magic":8,"difficulty":4},"image":{"full":"Sona.png","sprite":"champion4.png","group":"champion","x":336,"y":0,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":550,"hpperlevel":91,"mp":340,"mpperlevel":45,"movespeed":325,"armor":26,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":11.5,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":49,"attackdamageperlevel":0,"attackspeedperlevel":2.3,"attackspeed":0.644}},"Soraka":{"version":"16.10.1","id":"Soraka","key":"16","name":"Soraka","title":"the Starchild","blurb":"A wanderer from the celestial dimensions beyond Mount Targon, Soraka gave up her immortality to protect the mortal races from their own more violent instincts. She endeavors to spread the virtues of compassion and mercy to everyone she meets—even...","info":{"attack":2,"defense":5,"magic":7,"difficulty":3},"image":{"full":"Soraka.png","sprite":"champion4.png","group":"champion","x":384,"y":0,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":605,"hpperlevel":88,"mp":425,"mpperlevel":40,"movespeed":325,"armor":32,"armorperlevel":5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":2.5,"hpregenperlevel":0.5,"mpregen":11.5,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":50,"attackdamageperlevel":0,"attackspeedperlevel":2.14,"attackspeed":0.625}},"Swain":{"version":"16.10.1","id":"Swain","key":"50","name":"Swain","title":"the Noxian Grand General","blurb":"Jericho Swain is the visionary ruler of Noxus, an expansionist nation that reveres only strength. Though he was cast down and crippled in the Ionian wars, his left arm severed, he seized control of the empire with ruthless determination… and a new...","info":{"attack":2,"defense":6,"magic":9,"difficulty":8},"image":{"full":"Swain.png","sprite":"champion4.png","group":"champion","x":432,"y":0,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":595,"hpperlevel":99,"mp":400,"mpperlevel":29,"movespeed":330,"armor":25,"armorperlevel":4.7,"spellblock":31,"spellblockperlevel":1.55,"attackrange":525,"hpregen":3,"hpregenperlevel":0.5,"mpregen":10,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":2.11,"attackspeed":0.625}},"Sylas":{"version":"16.10.1","id":"Sylas","key":"517","name":"Sylas","title":"the Unshackled","blurb":"Raised in one of Demacia's lesser quarters, Sylas of Dregbourne has come to symbolize the darker side of the Great City. As a boy, his ability to root out hidden sorcery caught the attention of the notorious mageseekers, who eventually imprisoned him...","info":{"attack":3,"defense":4,"magic":8,"difficulty":5},"image":{"full":"Sylas.png","sprite":"champion4.png","group":"champion","x":0,"y":48,"w":48,"h":48},"tags":["Mage","Assassin"],"partype":"Mana","stats":{"hp":600,"hpperlevel":122,"mp":400,"mpperlevel":70,"movespeed":340,"armor":29,"armorperlevel":5.2,"spellblock":32,"spellblockperlevel":2.55,"attackrange":175,"hpregen":9,"hpregenperlevel":0.9,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":61,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.645}},"Syndra":{"version":"16.10.1","id":"Syndra","key":"134","name":"Syndra","title":"the Dark Sovereign","blurb":"Syndra is a fearsome Ionian mage with incredible power at her command. As a child, she disturbed the village elders with her reckless and wild magic. She was sent away to be taught greater control, but eventually discovered her supposed mentor was...","info":{"attack":2,"defense":3,"magic":9,"difficulty":8},"image":{"full":"Syndra.png","sprite":"champion4.png","group":"champion","x":48,"y":48,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":563,"hpperlevel":100,"mp":480,"mpperlevel":40,"movespeed":330,"armor":25,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":6.5,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":54,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.658}},"TahmKench":{"version":"16.10.1","id":"TahmKench","key":"223","name":"Tahm Kench","title":"The River King","blurb":"Known by many names throughout history, the demon Tahm Kench travels the waterways of Runeterra, feeding his insatiable appetite with the misery of others. Though he may appear singularly charming and proud, he swaggers through the physical realm like a...","info":{"attack":3,"defense":9,"magic":6,"difficulty":5},"image":{"full":"TahmKench.png","sprite":"champion4.png","group":"champion","x":96,"y":48,"w":48,"h":48},"tags":["Tank","Support"],"partype":"Mana","stats":{"hp":640,"hpperlevel":103,"mp":325,"mpperlevel":50,"movespeed":335,"armor":39,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":6.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":1,"crit":0,"critperlevel":0,"attackdamage":56,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.658}},"Taliyah":{"version":"16.10.1","id":"Taliyah","key":"163","name":"Taliyah","title":"the Stoneweaver","blurb":"Taliyah is a nomadic mage from Shurima, torn between teenage wonder and adult responsibility. She has crossed nearly all of Valoran on a journey to learn the true nature of her growing powers, though more recently she has returned to protect her tribe...","info":{"attack":1,"defense":7,"magic":8,"difficulty":5},"image":{"full":"Taliyah.png","sprite":"champion4.png","group":"champion","x":144,"y":48,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":550,"hpperlevel":104,"mp":470,"mpperlevel":30,"movespeed":330,"armor":18,"armorperlevel":4.7,"spellblock":28,"spellblockperlevel":1.3,"attackrange":525,"hpregen":6.5,"hpregenperlevel":0.65,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":1.36,"attackspeed":0.658}},"Talon":{"version":"16.10.1","id":"Talon","key":"91","name":"Talon","title":"the Blade's Shadow","blurb":"Talon is the knife in the darkness, a merciless killer able to strike without warning and escape before any alarm is raised. He carved out a dangerous reputation on the brutal streets of Noxus, where he was forced to fight, kill, and steal to survive...","info":{"attack":9,"defense":3,"magic":1,"difficulty":7},"image":{"full":"Talon.png","sprite":"champion4.png","group":"champion","x":192,"y":48,"w":48,"h":48},"tags":["Assassin"],"partype":"Mana","stats":{"hp":658,"hpperlevel":109,"mp":400,"mpperlevel":37,"movespeed":335,"armor":30,"armorperlevel":4.7,"spellblock":36,"spellblockperlevel":2.05,"attackrange":125,"hpregen":8.5,"hpregenperlevel":0.75,"mpregen":7.6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":68,"attackdamageperlevel":0,"attackspeedperlevel":2.9,"attackspeed":0.625}},"Taric":{"version":"16.10.1","id":"Taric","key":"44","name":"Taric","title":"the Shield of Valoran","blurb":"Taric is the Aspect of the Protector, wielding incredible power as Runeterra's guardian of life, love, and beauty. Shamed by a dereliction of duty and exiled from his homeland Demacia, Taric ascended Mount Targon to find redemption, only to discover a...","info":{"attack":4,"defense":8,"magic":5,"difficulty":3},"image":{"full":"Taric.png","sprite":"champion4.png","group":"champion","x":240,"y":48,"w":48,"h":48},"tags":["Support","Tank"],"partype":"Mana","stats":{"hp":645,"hpperlevel":99,"mp":300,"mpperlevel":60,"movespeed":340,"armor":40,"armorperlevel":4.3,"spellblock":28,"spellblockperlevel":2.05,"attackrange":150,"hpregen":6,"hpregenperlevel":0.5,"mpregen":8.5,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.625}},"Teemo":{"version":"16.10.1","id":"Teemo","key":"17","name":"Teemo","title":"the Swift Scout","blurb":"Undeterred by even the most dangerous and threatening of obstacles, Teemo scouts the world with boundless enthusiasm and a cheerful spirit. A yordle with an unwavering sense of morality, he takes pride in following the Bandle Scout's Code, sometimes...","info":{"attack":5,"defense":3,"magic":7,"difficulty":6},"image":{"full":"Teemo.png","sprite":"champion4.png","group":"champion","x":288,"y":48,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":615,"hpperlevel":104,"mp":334,"mpperlevel":25,"movespeed":330,"armor":24,"armorperlevel":4.95,"spellblock":30,"spellblockperlevel":1.3,"attackrange":500,"hpregen":5.5,"hpregenperlevel":0.65,"mpregen":9.6,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":54,"attackdamageperlevel":0,"attackspeedperlevel":3.38,"attackspeed":0.69}},"Thresh":{"version":"16.10.1","id":"Thresh","key":"412","name":"Thresh","title":"the Chain Warden","blurb":"Sadistic and cunning, Thresh is an ambitious and restless specter of the Shadow Isles. Once the custodian of countless arcane secrets, he was undone by a power greater than life or death, and now sustains himself by tormenting and breaking others with...","info":{"attack":5,"defense":6,"magic":6,"difficulty":7},"image":{"full":"Thresh.png","sprite":"champion4.png","group":"champion","x":336,"y":48,"w":48,"h":48},"tags":["Support","Tank"],"partype":"Mana","stats":{"hp":620,"hpperlevel":120,"mp":274,"mpperlevel":44,"movespeed":330,"armor":33,"armorperlevel":0,"spellblock":30,"spellblockperlevel":1.55,"attackrange":450,"hpregen":7,"hpregenperlevel":0.55,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":56,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.625}},"Tristana":{"version":"16.10.1","id":"Tristana","key":"18","name":"Tristana","title":"the Yordle Gunner","blurb":"While many other yordles channel their energy into discovery, invention, or just plain mischief-making, Tristana was always inspired by the adventures of great warriors. She had heard much about Runeterra, its factions, and its wars, and believed her...","info":{"attack":9,"defense":3,"magic":5,"difficulty":4},"image":{"full":"Tristana.png","sprite":"champion4.png","group":"champion","x":384,"y":48,"w":48,"h":48},"tags":["Marksman","Assassin"],"partype":"Mana","stats":{"hp":640,"hpperlevel":102,"mp":300,"mpperlevel":32,"movespeed":325,"armor":30,"armorperlevel":4,"spellblock":28,"spellblockperlevel":1.3,"attackrange":550,"hpregen":4,"hpregenperlevel":0.5,"mpregen":7.2,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":1.5,"attackspeed":0.656}},"Trundle":{"version":"16.10.1","id":"Trundle","key":"48","name":"Trundle","title":"the Troll King","blurb":"Trundle is a hulking and devious troll with a particularly vicious streak, and there is nothing he cannot bludgeon into submission—not even the Freljord itself. Fiercely territorial, he chases down anyone foolish enough to enter his domain. Then, his...","info":{"attack":7,"defense":6,"magic":2,"difficulty":5},"image":{"full":"Trundle.png","sprite":"champion4.png","group":"champion","x":432,"y":48,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":650,"hpperlevel":110,"mp":340,"mpperlevel":45,"movespeed":350,"armor":37,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":6,"hpregenperlevel":0.75,"mpregen":7.5,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":68,"attackdamageperlevel":0,"attackspeedperlevel":2.9,"attackspeed":0.67}},"Tryndamere":{"version":"16.10.1","id":"Tryndamere","key":"23","name":"Tryndamere","title":"the Barbarian King","blurb":"Fueled by unbridled fury and rage, Tryndamere once carved his way through the Freljord, openly challenging the greatest warriors of the north to prepare himself for even darker days ahead. The wrathful barbarian has long sought revenge for the...","info":{"attack":10,"defense":5,"magic":2,"difficulty":5},"image":{"full":"Tryndamere.png","sprite":"champion4.png","group":"champion","x":0,"y":96,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Fury","stats":{"hp":696,"hpperlevel":108,"mp":100,"mpperlevel":0,"movespeed":345,"armor":33,"armorperlevel":4.8,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":8.5,"hpregenperlevel":0.9,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":66,"attackdamageperlevel":0,"attackspeedperlevel":3.4,"attackspeed":0.67}},"TwistedFate":{"version":"16.10.1","id":"TwistedFate","key":"4","name":"Twisted Fate","title":"the Card Master","blurb":"Twisted Fate is an infamous cardsharp and swindler who has gambled and charmed his way across much of the known world, earning the enmity and admiration of the rich and foolish alike. He rarely takes things seriously, greeting each day with a mocking...","info":{"attack":6,"defense":2,"magic":6,"difficulty":9},"image":{"full":"TwistedFate.png","sprite":"champion4.png","group":"champion","x":48,"y":96,"w":48,"h":48},"tags":["Mage","Marksman"],"partype":"Mana","stats":{"hp":604,"hpperlevel":108,"mp":333,"mpperlevel":39,"movespeed":330,"armor":24,"armorperlevel":4.35,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":5.5,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":52,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.625}},"Twitch":{"version":"16.10.1","id":"Twitch","key":"29","name":"Twitch","title":"the Plague Rat","blurb":"A Zaunite plague rat by birth, but a connoisseur of filth by passion, Twitch is not afraid to get his paws dirty. Aiming a chem-powered crossbow at the gilded heart of Piltover, he has vowed to show those in the city above just how filthy they really...","info":{"attack":9,"defense":2,"magic":3,"difficulty":6},"image":{"full":"Twitch.png","sprite":"champion4.png","group":"champion","x":96,"y":96,"w":48,"h":48},"tags":["Marksman","Assassin"],"partype":"Mana","stats":{"hp":630,"hpperlevel":98,"mp":300,"mpperlevel":40,"movespeed":330,"armor":27,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":3.75,"hpregenperlevel":0.6,"mpregen":7.25,"mpregenperlevel":0.7,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.679}},"Udyr":{"version":"16.10.1","id":"Udyr","key":"77","name":"Udyr","title":"the Spirit Walker","blurb":"The most powerful spirit walker alive, Udyr communes with all the spirits of the Freljord, whether by empathically understanding their needs, or by channeling and transforming their ethereal energy into his own primal fighting style. He seeks balance...","info":{"attack":8,"defense":7,"magic":4,"difficulty":7},"image":{"full":"Udyr.png","sprite":"champion4.png","group":"champion","x":144,"y":96,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":664,"hpperlevel":92,"mp":271,"mpperlevel":50,"movespeed":350,"armor":31,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":6,"hpregenperlevel":0.75,"mpregen":7.5,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":3,"attackspeed":0.65}},"Urgot":{"version":"16.10.1","id":"Urgot","key":"6","name":"Urgot","title":"the Dreadnought","blurb":"Once a powerful Noxian headsman, Urgot was betrayed by the empire for which he had killed so many. Bound in iron chains, he was forced to learn the true meaning of strength in the Dredge—a prison mine deep beneath Zaun. Emerging in a disaster that...","info":{"attack":8,"defense":5,"magic":3,"difficulty":8},"image":{"full":"Urgot.png","sprite":"champion4.png","group":"champion","x":192,"y":96,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":655,"hpperlevel":102,"mp":340,"mpperlevel":45,"movespeed":330,"armor":36,"armorperlevel":5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":350,"hpregen":7.5,"hpregenperlevel":0.7,"mpregen":7.25,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":3.75,"attackspeed":0.625}},"Varus":{"version":"16.10.1","id":"Varus","key":"110","name":"Varus","title":"the Arrow of Retribution","blurb":"One of the ancient darkin, Varus was a deadly killer who loved to torment his foes, driving them almost to insanity before delivering the killing arrow. He was imprisoned at the end of the Great Darkin War, but escaped centuries later in the remade...","info":{"attack":7,"defense":3,"magic":4,"difficulty":2},"image":{"full":"Varus.png","sprite":"champion4.png","group":"champion","x":240,"y":96,"w":48,"h":48},"tags":["Marksman","Mage"],"partype":"Mana","stats":{"hp":600,"hpperlevel":105,"mp":320,"mpperlevel":40,"movespeed":330,"armor":24,"armorperlevel":4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":575,"hpregen":3.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":59,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.658}},"Vayne":{"version":"16.10.1","id":"Vayne","key":"67","name":"Vayne","title":"the Night Hunter","blurb":"Shauna Vayne is a deadly, remorseless Demacian monster hunter, who has dedicated her life to finding and destroying the demon that murdered her family. Armed with a wrist-mounted crossbow and a heart full of vengeance, she is only truly happy when...","info":{"attack":10,"defense":1,"magic":1,"difficulty":8},"image":{"full":"Vayne.png","sprite":"champion4.png","group":"champion","x":288,"y":96,"w":48,"h":48},"tags":["Marksman","Assassin"],"partype":"Mana","stats":{"hp":550,"hpperlevel":103,"mp":232,"mpperlevel":35,"movespeed":330,"armor":23,"armorperlevel":4.6,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":3.5,"hpregenperlevel":0.55,"mpregen":7,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":3.3,"attackspeed":0.658}},"Veigar":{"version":"16.10.1","id":"Veigar","key":"45","name":"Veigar","title":"the Tiny Master of Evil","blurb":"An enthusiastic master of dark sorcery, Veigar has embraced powers that few mortals dare approach. As a free-spirited inhabitant of Bandle City, he longed to push beyond the limitations of yordle magic, and turned instead to arcane texts that had been...","info":{"attack":2,"defense":2,"magic":10,"difficulty":7},"image":{"full":"Veigar.png","sprite":"champion4.png","group":"champion","x":336,"y":96,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":580,"hpperlevel":108,"mp":490,"mpperlevel":26,"movespeed":340,"armor":18,"armorperlevel":5.2,"spellblock":32,"spellblockperlevel":1.3,"attackrange":550,"hpregen":6.5,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":52,"attackdamageperlevel":0,"attackspeedperlevel":2.24,"attackspeed":0.625}},"Velkoz":{"version":"16.10.1","id":"Velkoz","key":"161","name":"Vel'Koz","title":"the Eye of the Void","blurb":"It is unclear if Vel'Koz was the first Void-spawn to emerge on Runeterra, but there has certainly never been another to match his level of cruel, calculating sentience. While his kin devour or defile everything around them, he seeks instead to...","info":{"attack":2,"defense":2,"magic":10,"difficulty":8},"image":{"full":"Velkoz.png","sprite":"champion4.png","group":"champion","x":384,"y":96,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":590,"hpperlevel":102,"mp":469,"mpperlevel":21,"movespeed":340,"armor":22,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":1.59,"attackspeed":0.643}},"Vex":{"version":"16.10.1","id":"Vex","key":"711","name":"Vex","title":"the Gloomist","blurb":"In the black heart of the Shadow Isles, a lone yordle trudges through the spectral fog, content in its murky misery. With an endless supply of teen angst and a powerful shadow in tow, Vex lives in her own self-made slice of gloom, far from the revolting...","info":{"attack":0,"defense":0,"magic":0,"difficulty":0},"image":{"full":"Vex.png","sprite":"champion4.png","group":"champion","x":432,"y":96,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":590,"hpperlevel":104,"mp":490,"mpperlevel":32,"movespeed":335,"armor":23,"armorperlevel":4.45,"spellblock":28,"spellblockperlevel":1.3,"attackrange":550,"hpregen":6.5,"hpregenperlevel":0.6,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":54,"attackdamageperlevel":0,"attackspeedperlevel":1,"attackspeed":0.669}},"Vi":{"version":"16.10.1","id":"Vi","key":"254","name":"Vi","title":"the Piltover Enforcer","blurb":"Raised on the mean streets of Zaun, Vi is a hotheaded, impulsive, and fearsome woman with very little respect for authority. She has always been a shrewd survivor, both from her youthful troublemaking topside and an unfairly long stint in Stillwater...","info":{"attack":8,"defense":5,"magic":3,"difficulty":4},"image":{"full":"Vi.png","sprite":"champion5.png","group":"champion","x":0,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":655,"hpperlevel":105,"mp":295,"mpperlevel":65,"movespeed":340,"armor":30,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":10,"hpregenperlevel":1,"mpregen":8,"mpregenperlevel":0.65,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.644}},"Viego":{"version":"16.10.1","id":"Viego","key":"234","name":"Viego","title":"The Ruined King","blurb":"Once ruler of a long-lost kingdom, Viego perished over a thousand years ago when his attempt to bring his wife back from the dead triggered the magical catastrophe known as the Ruination. Transformed into a powerful, unliving specter tortured by an...","info":{"attack":6,"defense":4,"magic":2,"difficulty":5},"image":{"full":"Viego.png","sprite":"champion5.png","group":"champion","x":48,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"None","stats":{"hp":630,"hpperlevel":109,"mp":10000,"mpperlevel":0,"movespeed":345,"armor":34,"armorperlevel":4.6,"spellblock":32,"spellblockperlevel":2.05,"attackrange":200,"hpregen":7,"hpregenperlevel":0.7,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":57,"attackdamageperlevel":0,"attackspeedperlevel":2.75,"attackspeed":0.658}},"Viktor":{"version":"16.10.1","id":"Viktor","key":"112","name":"Viktor","title":"the Herald of the Arcane","blurb":"The fully biomechanical evolution of his former self, Viktor has embraced his Glorious Evolution and become something of a messiah to his followers. He sacrificed his own humanity under the logic that eliminating emotion would thereby eliminate...","info":{"attack":2,"defense":4,"magic":10,"difficulty":9},"image":{"full":"Viktor.png","sprite":"champion5.png","group":"champion","x":96,"y":0,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":600,"hpperlevel":100,"mp":405,"mpperlevel":45,"movespeed":335,"armor":23,"armorperlevel":4.4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":8,"hpregenperlevel":0.65,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":53,"attackdamageperlevel":0,"attackspeedperlevel":2.11,"attackspeed":0.658}},"Vladimir":{"version":"16.10.1","id":"Vladimir","key":"8","name":"Vladimir","title":"the Crimson Reaper","blurb":"A fiend with a thirst for mortal blood, Vladimir has influenced the affairs of Noxus since the empire's earliest days. In addition to unnaturally extending his life, his mastery of hemomancy allows him to control the minds and bodies of others as easily...","info":{"attack":2,"defense":6,"magic":8,"difficulty":7},"image":{"full":"Vladimir.png","sprite":"champion5.png","group":"champion","x":144,"y":0,"w":48,"h":48},"tags":["Mage","Fighter"],"partype":"Crimson Rush","stats":{"hp":600,"hpperlevel":110,"mp":2,"mpperlevel":0,"movespeed":330,"armor":24,"armorperlevel":4.5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":450,"hpregen":7,"hpregenperlevel":0.6,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.658}},"Volibear":{"version":"16.10.1","id":"Volibear","key":"106","name":"Volibear","title":"the Relentless Storm","blurb":"To those who still revere him, the Volibear is the storm made manifest. Destructive, wild, and stubbornly resolute, he existed before mortals walked the Freljord's tundra, and is fiercely protective of the lands that he and his demi-god kin created...","info":{"attack":7,"defense":7,"magic":4,"difficulty":3},"image":{"full":"Volibear.png","sprite":"champion5.png","group":"champion","x":192,"y":0,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":650,"hpperlevel":104,"mp":350,"mpperlevel":70,"movespeed":340,"armor":35,"armorperlevel":5.2,"spellblock":32,"spellblockperlevel":2.05,"attackrange":150,"hpregen":9,"hpregenperlevel":0.75,"mpregen":6.25,"mpregenperlevel":0.5,"crit":0,"critperlevel":0,"attackdamage":65,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.625}},"Warwick":{"version":"16.10.1","id":"Warwick","key":"19","name":"Warwick","title":"the Uncaged Wrath of Zaun","blurb":"Warwick is a monster who hunts the gray alleys of Zaun. Transformed by agonizing experiments, his body is fused with an intricate system of chambers and pumps, machinery filling his veins with alchemical rage. He bursts from the shadows to prey upon...","info":{"attack":9,"defense":5,"magic":3,"difficulty":3},"image":{"full":"Warwick.png","sprite":"champion5.png","group":"champion","x":240,"y":0,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":620,"hpperlevel":99,"mp":280,"mpperlevel":35,"movespeed":335,"armor":33,"armorperlevel":4.4,"spellblock":32,"spellblockperlevel":2.05,"attackrange":125,"hpregen":4,"hpregenperlevel":0.75,"mpregen":7.45,"mpregenperlevel":0.6,"crit":0,"critperlevel":0,"attackdamage":65,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.638}},"Xayah":{"version":"16.10.1","id":"Xayah","key":"498","name":"Xayah","title":"the Rebel","blurb":"Deadly and precise, Xayah is a vastayan revolutionary waging a personal war to save her people. She uses her speed, guile, and razor-sharp feather blades to cut down anyone who stands in her way. Xayah fights alongside her partner and lover, Rakan, to...","info":{"attack":10,"defense":6,"magic":1,"difficulty":5},"image":{"full":"Xayah.png","sprite":"champion5.png","group":"champion","x":288,"y":0,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":630,"hpperlevel":107,"mp":340,"mpperlevel":40,"movespeed":330,"armor":25,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":3.25,"hpregenperlevel":0.75,"mpregen":8.25,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":3.9,"attackspeed":0.658}},"Xerath":{"version":"16.10.1","id":"Xerath","key":"101","name":"Xerath","title":"the Magus Ascendant","blurb":"Xerath is an Ascended Magus of ancient Shurima, a being of arcane energy writhing in the broken shards of a magical sarcophagus. For millennia, he was trapped beneath the desert sands, but the rise of Shurima freed him from his ancient prison. Driven...","info":{"attack":1,"defense":3,"magic":10,"difficulty":8},"image":{"full":"Xerath.png","sprite":"champion5.png","group":"champion","x":336,"y":0,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":596,"hpperlevel":106,"mp":400,"mpperlevel":22,"movespeed":340,"armor":22,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":525,"hpregen":5.5,"hpregenperlevel":0.55,"mpregen":6.85,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":1.36,"attackspeed":0.658}},"XinZhao":{"version":"16.10.1","id":"XinZhao","key":"5","name":"Xin Zhao","title":"the Seneschal of Demacia","blurb":"Xin Zhao is a resolute warrior loyal to the ruling Lightshield dynasty. Once condemned to the fighting pits of Noxus, he survived countless gladiatorial bouts, but after being freed by Demacian forces, he swore his life and allegiance to these brave...","info":{"attack":8,"defense":6,"magic":3,"difficulty":2},"image":{"full":"XinZhao.png","sprite":"champion5.png","group":"champion","x":384,"y":0,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":620,"hpperlevel":106,"mp":274,"mpperlevel":55,"movespeed":345,"armor":35,"armorperlevel":4.4,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":8,"hpregenperlevel":0.7,"mpregen":7.25,"mpregenperlevel":0.45,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.645}},"Yasuo":{"version":"16.10.1","id":"Yasuo","key":"157","name":"Yasuo","title":"the Unforgiven","blurb":"An Ionian of deep resolve, Yasuo is an agile swordsman who wields the air itself against his enemies. As a proud young man, he was falsely accused of murdering his master—unable to prove his innocence, he was forced to slay his own brother in self...","info":{"attack":8,"defense":4,"magic":4,"difficulty":10},"image":{"full":"Yasuo.png","sprite":"champion5.png","group":"champion","x":432,"y":0,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Flow","stats":{"hp":590,"hpperlevel":110,"mp":100,"mpperlevel":0,"movespeed":345,"armor":32,"armorperlevel":4.6,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":6.5,"hpregenperlevel":0.9,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.697}},"Yone":{"version":"16.10.1","id":"Yone","key":"777","name":"Yone","title":"the Unforgotten","blurb":"In life, he was Yone—half-brother of Yasuo, and renowned student of his village's sword school. But upon his death at the hands of his brother, he found himself hunted by a malevolent entity of the spirit realm, and was forced to slay it with its own...","info":{"attack":8,"defense":4,"magic":4,"difficulty":8},"image":{"full":"Yone.png","sprite":"champion5.png","group":"champion","x":0,"y":48,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Flow","stats":{"hp":620,"hpperlevel":105,"mp":500,"mpperlevel":0,"movespeed":345,"armor":33,"armorperlevel":4.6,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":7.5,"hpregenperlevel":0.75,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":3.5,"attackspeed":0.625}},"Yorick":{"version":"16.10.1","id":"Yorick","key":"83","name":"Yorick","title":"Shepherd of Souls","blurb":"The last survivor of a long-forgotten religious order, Yorick is both blessed and cursed with power over the dead. Trapped on the Shadow Isles, his only companions are the rotting corpses and shrieking wraiths that he gathers to him. Yorick's monstrous...","info":{"attack":6,"defense":6,"magic":4,"difficulty":6},"image":{"full":"Yorick.png","sprite":"champion5.png","group":"champion","x":48,"y":48,"w":48,"h":48},"tags":["Fighter","Tank"],"partype":"Mana","stats":{"hp":650,"hpperlevel":114,"mp":300,"mpperlevel":60,"movespeed":340,"armor":36,"armorperlevel":4.5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":8,"hpregenperlevel":0.8,"mpregen":7.5,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":62,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.625}},"Yunara":{"version":"16.10.1","id":"Yunara","key":"804","name":"Yunara","title":"the Unbroken Faith","blurb":"Unwavering in her devotion to Ionia, Yunara has spent centuries cloistered away in the spirit realm honing her skills with the Aion Er'na, a legendary Kinkou relic. Despite all she has sacrificed, Yunara's vow to rid the land of disharmony and strife...","info":{"attack":9,"defense":2,"magic":0,"difficulty":4},"image":{"full":"Yunara.png","sprite":"champion5.png","group":"champion","x":96,"y":48,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":590,"hpperlevel":110,"mp":275,"mpperlevel":45,"movespeed":325,"armor":25,"armorperlevel":4.4,"spellblock":30,"spellblockperlevel":1.3,"attackrange":575,"hpregen":4,"hpregenperlevel":0.55,"mpregen":7.5,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.65}},"Yuumi":{"version":"16.10.1","id":"Yuumi","key":"350","name":"Yuumi","title":"the Magical Cat","blurb":"A magical cat from Bandle City, Yuumi was once the familiar of a yordle enchantress, Norra. When her master mysteriously disappeared, Yuumi became the Keeper of Norra's sentient Book of Thresholds, traveling through portals in its pages to search for...","info":{"attack":5,"defense":1,"magic":8,"difficulty":2},"image":{"full":"Yuumi.png","sprite":"champion5.png","group":"champion","x":144,"y":48,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":500,"hpperlevel":69,"mp":440,"mpperlevel":45,"movespeed":330,"armor":25,"armorperlevel":4.2,"spellblock":25,"spellblockperlevel":1.1,"attackrange":425,"hpregen":5,"hpregenperlevel":0.55,"mpregen":10,"mpregenperlevel":0.4,"crit":0,"critperlevel":0,"attackdamage":49,"attackdamageperlevel":0,"attackspeedperlevel":1,"attackspeed":0.625}},"Zaahen":{"version":"16.10.1","id":"Zaahen","key":"904","name":"Zaahen","title":"The Unsundered","blurb":"A fallen god wielding both divine and profane power, Zaahen hunts his fellow Darkin while defying the corruption that threatens to consume him. Once willingly sealed within his glaive to stave off madness, he now walks free, noble in heart and vicious...","info":{"attack":8,"defense":6,"magic":1,"difficulty":2},"image":{"full":"Zaahen.png","sprite":"champion5.png","group":"champion","x":192,"y":48,"w":48,"h":48},"tags":["Fighter","Assassin"],"partype":"Mana","stats":{"hp":640,"hpperlevel":114,"mp":350,"mpperlevel":55,"movespeed":345,"armor":36,"armorperlevel":5,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":7.5,"hpregenperlevel":0.8,"mpregen":8.15,"mpregenperlevel":0.75,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.625}},"Zac":{"version":"16.10.1","id":"Zac","key":"154","name":"Zac","title":"the Secret Weapon","blurb":"Zac is the product of a toxic spill that ran through a chemtech seam and pooled in an isolated cavern deep in Zaun's Sump. Despite such humble origins, Zac has grown from primordial ooze into a thinking being who dwells in the city's pipes, occasionally...","info":{"attack":3,"defense":7,"magic":7,"difficulty":8},"image":{"full":"Zac.png","sprite":"champion5.png","group":"champion","x":240,"y":48,"w":48,"h":48},"tags":["Tank","Fighter"],"partype":"None","stats":{"hp":685,"hpperlevel":109,"mp":0,"mpperlevel":0,"movespeed":340,"armor":33,"armorperlevel":4.7,"spellblock":32,"spellblockperlevel":2.05,"attackrange":175,"hpregen":5,"hpregenperlevel":0.5,"mpregen":0,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":60,"attackdamageperlevel":0,"attackspeedperlevel":1.6,"attackspeed":0.736}},"Zed":{"version":"16.10.1","id":"Zed","key":"238","name":"Zed","title":"the Master of Shadows","blurb":"Utterly ruthless and without mercy, Zed is the leader of the Order of Shadow, an organization he created with the intent of militarizing Ionia's magical and martial traditions to drive out Noxian invaders. During the war, desperation led him to unlock...","info":{"attack":9,"defense":2,"magic":1,"difficulty":7},"image":{"full":"Zed.png","sprite":"champion5.png","group":"champion","x":288,"y":48,"w":48,"h":48},"tags":["Assassin"],"partype":"Energy","stats":{"hp":654,"hpperlevel":99,"mp":200,"mpperlevel":0,"movespeed":345,"armor":32,"armorperlevel":4.7,"spellblock":29,"spellblockperlevel":2.05,"attackrange":125,"hpregen":7,"hpregenperlevel":0.65,"mpregen":50,"mpregenperlevel":0,"crit":0,"critperlevel":0,"attackdamage":63,"attackdamageperlevel":0,"attackspeedperlevel":3.3,"attackspeed":0.651}},"Zeri":{"version":"16.10.1","id":"Zeri","key":"221","name":"Zeri","title":"The Spark of Zaun","blurb":"A headstrong, spirited young woman from Zaun's working-class, Zeri channels her electric magic to charge herself and her custom-crafted gun. Her volatile power mirrors her emotions, its sparks reflecting her lightning-fast approach to life. Deeply...","info":{"attack":8,"defense":5,"magic":3,"difficulty":6},"image":{"full":"Zeri.png","sprite":"champion5.png","group":"champion","x":336,"y":48,"w":48,"h":48},"tags":["Marksman"],"partype":"Mana","stats":{"hp":600,"hpperlevel":110,"mp":250,"mpperlevel":45,"movespeed":330,"armor":24,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":3.25,"hpregenperlevel":0.7,"mpregen":6,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":56,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.658}},"Ziggs":{"version":"16.10.1","id":"Ziggs","key":"115","name":"Ziggs","title":"the Hexplosives Expert","blurb":"With a love of big bombs and short fuses, the yordle Ziggs is an explosive force of nature. As an inventor's assistant in Piltover, he was bored by his predictable life and befriended a mad, blue-haired bomber named Jinx. After a wild night on the town...","info":{"attack":2,"defense":4,"magic":9,"difficulty":4},"image":{"full":"Ziggs.png","sprite":"champion5.png","group":"champion","x":384,"y":48,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":606,"hpperlevel":106,"mp":480,"mpperlevel":23.5,"movespeed":325,"armor":21,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":6.5,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":55,"attackdamageperlevel":0,"attackspeedperlevel":2,"attackspeed":0.656}},"Zilean":{"version":"16.10.1","id":"Zilean","key":"26","name":"Zilean","title":"the Chronokeeper","blurb":"Once a powerful Icathian mage, Zilean became obsessed with the passage of time after witnessing his homeland's destruction by the Void. Unable to spare even a minute to grieve the catastrophic loss, he called upon ancient temporal magic to divine all...","info":{"attack":2,"defense":5,"magic":8,"difficulty":6},"image":{"full":"Zilean.png","sprite":"champion5.png","group":"champion","x":432,"y":48,"w":48,"h":48},"tags":["Support","Mage"],"partype":"Mana","stats":{"hp":574,"hpperlevel":96,"mp":452,"mpperlevel":50,"movespeed":335,"armor":24,"armorperlevel":5,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":5.5,"hpregenperlevel":0.5,"mpregen":11.35,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":52,"attackdamageperlevel":0,"attackspeedperlevel":2.13,"attackspeed":0.658}},"Zoe":{"version":"16.10.1","id":"Zoe","key":"142","name":"Zoe","title":"the Aspect of Twilight","blurb":"As the embodiment of mischief, imagination, and change, Zoe acts as the cosmic messenger of Targon, heralding major events that reshape worlds. Her mere presence warps the arcane mathematics governing realities, sometimes causing cataclysms without...","info":{"attack":1,"defense":7,"magic":8,"difficulty":5},"image":{"full":"Zoe.png","sprite":"champion5.png","group":"champion","x":0,"y":96,"w":48,"h":48},"tags":["Mage"],"partype":"Mana","stats":{"hp":630,"hpperlevel":106,"mp":425,"mpperlevel":25,"movespeed":340,"armor":21,"armorperlevel":4.7,"spellblock":30,"spellblockperlevel":1.3,"attackrange":550,"hpregen":7.5,"hpregenperlevel":0.6,"mpregen":8,"mpregenperlevel":0.65,"crit":0,"critperlevel":0,"attackdamage":58,"attackdamageperlevel":0,"attackspeedperlevel":2.5,"attackspeed":0.658}},"Zyra":{"version":"16.10.1","id":"Zyra","key":"143","name":"Zyra","title":"Rise of the Thorns","blurb":"Born in an ancient, sorcerous catastrophe, Zyra is the wrath of nature given form—an alluring hybrid of plant and human, kindling new life with every step. She views the many mortals of Valoran as little more than prey for her seeded progeny, and thinks...","info":{"attack":4,"defense":3,"magic":8,"difficulty":7},"image":{"full":"Zyra.png","sprite":"champion5.png","group":"champion","x":48,"y":96,"w":48,"h":48},"tags":["Mage","Support"],"partype":"Mana","stats":{"hp":574,"hpperlevel":93,"mp":418,"mpperlevel":25,"movespeed":340,"armor":29,"armorperlevel":4.2,"spellblock":30,"spellblockperlevel":1.3,"attackrange":575,"hpregen":5.5,"hpregenperlevel":0.5,"mpregen":7,"mpregenperlevel":0.8,"crit":0,"critperlevel":0,"attackdamage":53,"attackdamageperlevel":0,"attackspeedperlevel":2.11,"attackspeed":0.681}}}} \ No newline at end of file diff --git a/backend/app/database/erd_my db post.png b/backend/app/database/erd_my db post.png new file mode 100644 index 00000000..04a4d25f Binary files /dev/null and b/backend/app/database/erd_my db post.png differ diff --git a/backend/app/database/models.py b/backend/app/database/models.py index e5c84f37..d8d4cc26 100644 --- a/backend/app/database/models.py +++ b/backend/app/database/models.py @@ -1,4 +1,7 @@ +from datetime import date, datetime, timezone from typing import List, Optional + +from sqlalchemy import BigInteger, Column, UniqueConstraint from sqlmodel import SQLModel, Field, Relationship # @NeoMachabaUP : @@ -8,6 +11,21 @@ # Likely to get more complex as we add more features but this is a good starting point for the basic match/summoner/champion data we need to store. +# added for profile +class UserProfile(SQLModel, table=True): + user_id: str = Field(primary_key=True) + username: str + riot_puuid: Optional[str] = Field(default=None, foreign_key="game_accounts.puuid") + + deletion_scheduled_at: Optional[datetime] = None + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + + # Champions # Stores static champion data. champion_id matches Riot's own ID system so we won't change that. # we can cross reference api responses directly without a lookup step making it easier to confirm data integrity. @@ -19,18 +37,56 @@ class Champions(SQLModel, table=True): participants: List["Participants"] = Relationship(back_populates="champion") -# Summoners +# Users +# Represents a registered Vantage Point account. +class Users(SQLModel, table=True): + id: str = Field(primary_key=True) + email: str = Field(unique=True, index=True) + password_hash: str + display_name: str + avatar_url: str | None = None + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None) + ) + + linked_game_accounts: List["UserGameAccounts"] = Relationship(back_populates="user") + + +# GameAccounts # THIS IS A PLAYER ACCOUNT. # PUUID is Riot's global unique identifier for a player SO DO NOT TOUCH IT # I REPEAT DO NOT MESS WITH PUUID. # this stays the same acorss regions and name changes which is why we use it as the primary key. We can always look up the current name and tag using the PUUID. -class Summoners(SQLModel, table=True): +class GameAccounts(SQLModel, table=True): + __tablename__ = "game_accounts" + puuid: str = Field(primary_key=True) + game: str # identifies which game this account belongs to e.g. "league_of_legends", "dota2" game_name: str tag_line: str # the part after '#' in Riot ID, e.g. "EUW" in "Player#EUW" - summoner_level: int + account_level: int + profile_matches_sampled: Optional[int] = None + + linked_users: List["UserGameAccounts"] = Relationship(back_populates="game_account") + participations: List["Participants"] = Relationship(back_populates="game_account") + achievements: List["UserAchievements"] = Relationship(back_populates="game_account") + featured_games: List["UserFeaturedGames"] = Relationship( + back_populates="game_account" + ) + - participations: List["Participants"] = Relationship(back_populates="summoner") +# UserGameAccounts +# Join table: tracks which game accounts a user has linked to their account. +# A user can track many game accounts, and a game account can be tracked by many users. +class UserGameAccounts(SQLModel, table=True): + __tablename__ = "user_game_accounts" + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: str = Field(foreign_key="users.id") + puuid: str = Field(foreign_key="game_accounts.puuid") + + user: "Users" = Relationship(back_populates="linked_game_accounts") + game_account: "GameAccounts" = Relationship(back_populates="linked_users") # Matches @@ -45,6 +101,10 @@ class Matches(SQLModel, table=True): game_version: str # Patch the game was played on, e.g. "13.12" - this is important for tracking balance changes and how they affect champion performance over time. game_duration: int # in seconds; queue_id: int + game_creation: int = Field(default=0, sa_column=Column(BigInteger())) # epoch ms + map_id: int = 11 + played_on: date = Field(default_factory=lambda: date.today()) + detail_json: Optional[str] = None # JSON: MatchDetail teams payload for scoreboard participants: List["Participants"] = Relationship(back_populates="match") @@ -57,16 +117,77 @@ class Participants(SQLModel, table=True): internal_id: Optional[int] = Field(default=None, primary_key=True) match_id: str = Field(foreign_key="matches.match_id") - puuid: str = Field(foreign_key="summoners.puuid") + puuid: str = Field(foreign_key="game_accounts.puuid") champion_id: int = Field(foreign_key="champions.champion_id") + team_id: int = 100 win: bool kills: int deaths: int assists: int individual_position: str # Riot's assigned lane: "TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY" ; will use this later to determine role for champion mastery and other stats + cs: int = 0 + gold_earned: int = 0 + damage_to_champions: int = 0 + vision_score: int = 0 + items_json: str = "[]" + summoner_spells_json: str = "[4, 14]" + riot_id_display: Optional[str] = None + kill_participation: Optional[float] = None # Below are back-references so we can navigate from a participant to its match/player/champion match: "Matches" = Relationship(back_populates="participants") - summoner: "Summoners" = Relationship(back_populates="participations") + game_account: "GameAccounts" = Relationship(back_populates="participations") champion: "Champions" = Relationship(back_populates="participants") + + +# AchievementDefinitions +# Catalog of achievement types shown on the profile (labels map to frontend icon ids). +class AchievementDefinitions(SQLModel, table=True): + __tablename__ = "achievement_definitions" + + id: str = Field(primary_key=True) + label: str + description: str + source_field: str + + user_achievements: List["UserAchievements"] = Relationship( + back_populates="definition" + ) + + +# UserAchievements +# Per-player achievement counts for the profile achievements row. +class UserAchievements(SQLModel, table=True): + __tablename__ = "user_achievements" + __table_args__ = (UniqueConstraint("puuid", "achievement_id"),) + + id: Optional[int] = Field(default=None, primary_key=True) + puuid: str = Field(foreign_key="game_accounts.puuid") + achievement_id: str = Field(foreign_key="achievement_definitions.id") + count: int + + game_account: "GameAccounts" = Relationship(back_populates="achievements") + definition: "AchievementDefinitions" = Relationship( + back_populates="user_achievements" + ) + + +# UserFeaturedGames +# Featured-game banner slides on the profile (marketing / summary cards). +class UserFeaturedGames(SQLModel, table=True): + __tablename__ = "user_featured_games" + + id: Optional[int] = Field(default=None, primary_key=True) + puuid: str = Field(foreign_key="game_accounts.puuid") + sort_order: int = 0 + game_name: str + cover_image_key: str + card_image_key: Optional[str] = None + efficiency_score: int + time_spent_seconds: int + wins: int + losses: int + average_kda: float + + game_account: "GameAccounts" = Relationship(back_populates="featured_games") diff --git a/backend/app/database/seed.py b/backend/app/database/seed.py new file mode 100644 index 00000000..4fd35f5b --- /dev/null +++ b/backend/app/database/seed.py @@ -0,0 +1,347 @@ +import asyncio +import os +from datetime import datetime, timezone + +# TODO: add timezone handling if we need it later, for now we just store naive UTC datetimes which is fine for our use case. +from dotenv import load_dotenv +from sqlmodel import SQLModel +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession + +from app.database.models import ( + Champions, + Users, + GameAccounts, + UserGameAccounts, + Matches, + Participants, +) +from app.database.seed_data import ( + PROFILE_MATCHES_SAMPLED, + SEED_MATCHES, + SEED_VIEWER_PARTICIPANTS, + SEED_USER_ACHIEVEMENTS, + SEED_FEATURED_GAMES, + VIEWER_PUUID, +) +from app.database.seed_profile import seed_profile_for_puuid +from app.database.seed_data.matches import build_detail_json, game_creation_for + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + raise ValueError( + "DATABASE_URL environment variable not set. " + "Please define it in your .env file or environment." + ) +engine = create_async_engine(DATABASE_URL, echo=False) + +# Champion IDs sourced from Riot Data Dragon (patch 12.1). +# Names and classes cross-referenced with Champion_Stats_12_1 dataset. +# Format: (champion_id, name, tags) +CHAMPIONS = [ + (266, "Aatrox", "Fighter"), + (103, "Ahri", "Mage"), + (84, "Akali", "Assassin"), + (166, "Akshan", "Marksman"), + (12, "Alistar", "Tank"), + (32, "Amumu", "Tank"), + (34, "Anivia", "Mage"), + (1, "Annie", "Mage"), + (523, "Aphelios", "Marksman"), + (22, "Ashe", "Marksman"), + (136, "Aurelion Sol", "Mage"), + (268, "Azir", "Mage"), + (432, "Bard", "Support"), + (53, "Blitzcrank", "Tank"), + (63, "Brand", "Mage"), + (201, "Braum", "Support"), + (51, "Caitlyn", "Marksman"), + (164, "Camille", "Fighter"), + (69, "Cassiopeia", "Mage"), + (31, "Cho'Gath", "Tank"), + (42, "Corki", "Marksman"), + (122, "Darius", "Fighter"), + (131, "Diana", "Fighter"), + (36, "Dr. Mundo", "Fighter"), + (119, "Draven", "Marksman"), + (245, "Ekko", "Assassin"), + (60, "Elise", "Mage"), + (28, "Evelynn", "Assassin"), + (81, "Ezreal", "Marksman"), + (9, "Fiddlesticks", "Mage"), + (114, "Fiora", "Fighter"), + (105, "Fizz", "Assassin"), + (3, "Galio", "Tank"), + (41, "Gangplank", "Fighter"), + (86, "Garen", "Fighter"), + (150, "Gnar", "Fighter"), + (79, "Gragas", "Fighter"), + (104, "Graves", "Marksman"), + (887, "Gwen", "Fighter"), + (120, "Hecarim", "Fighter"), + (74, "Heimerdinger", "Mage"), + (420, "Illaoi", "Fighter"), + (39, "Irelia", "Fighter"), + (427, "Ivern", "Support"), + (40, "Janna", "Support"), + (59, "Jarvan IV", "Tank"), + (24, "Jax", "Fighter"), + (126, "Jayce", "Fighter"), + (202, "Jhin", "Marksman"), + (222, "Jinx", "Marksman"), + (145, "Kai'Sa", "Marksman"), + (429, "Kalista", "Marksman"), + (43, "Karma", "Mage"), + (30, "Karthus", "Mage"), + (38, "Kassadin", "Assassin"), + (55, "Katarina", "Assassin"), + (10, "Kayle", "Fighter"), + (141, "Kayn", "Fighter"), + (85, "Kennen", "Mage"), + (121, "Kha'Zix", "Assassin"), + (203, "Kindred", "Marksman"), + (240, "Kled", "Fighter"), + (96, "Kog'Maw", "Marksman"), + (7, "LeBlanc", "Assassin"), + (64, "Lee Sin", "Fighter"), + (89, "Leona", "Tank"), + (876, "Lillia", "Fighter"), + (127, "Lissandra", "Mage"), + (236, "Lucian", "Marksman"), + (117, "Lulu", "Support"), + (99, "Lux", "Mage"), + (54, "Malphite", "Tank"), + (90, "Malzahar", "Mage"), + (57, "Maokai", "Tank"), + (11, "Master Yi", "Assassin"), + (21, "Miss Fortune", "Marksman"), + (82, "Mordekaiser", "Fighter"), + (25, "Morgana", "Mage"), + (267, "Nami", "Support"), + (75, "Nasus", "Fighter"), + (111, "Nautilus", "Tank"), + (518, "Neeko", "Mage"), + (76, "Nidalee", "Assassin"), + (56, "Nocturne", "Assassin"), + (20, "Nunu", "Tank"), + (2, "Olaf", "Fighter"), + (61, "Orianna", "Mage"), + (516, "Ornn", "Tank"), + (80, "Pantheon", "Fighter"), + (78, "Poppy", "Tank"), + (555, "Pyke", "Support"), + (246, "Qiyana", "Assassin"), + (133, "Quinn", "Marksman"), + (497, "Rakan", "Support"), + (33, "Rammus", "Tank"), + (421, "Rek'Sai", "Fighter"), + (526, "Rell", "Tank"), + (58, "Renekton", "Fighter"), + (107, "Rengar", "Assassin"), + (92, "Riven", "Fighter"), + (68, "Rumble", "Fighter"), + (13, "Ryze", "Mage"), + (360, "Samira", "Marksman"), + (113, "Sejuani", "Tank"), + (235, "Senna", "Marksman"), + (147, "Seraphine", "Mage"), + (875, "Sett", "Fighter"), + (35, "Shaco", "Assassin"), + (98, "Shen", "Tank"), + (102, "Shyvana", "Fighter"), + (27, "Singed", "Tank"), + (14, "Sion", "Tank"), + (15, "Sivir", "Marksman"), + (72, "Skarner", "Fighter"), + (37, "Sona", "Support"), + (16, "Soraka", "Support"), + (50, "Swain", "Mage"), + (517, "Sylas", "Mage"), + (134, "Syndra", "Mage"), + (223, "Tahm Kench", "Support"), + (163, "Taliyah", "Mage"), + (91, "Talon", "Assassin"), + (44, "Taric", "Support"), + (17, "Teemo", "Marksman"), + (412, "Thresh", "Support"), + (18, "Tristana", "Marksman"), + (48, "Trundle", "Fighter"), + (23, "Tryndamere", "Fighter"), + (4, "Twisted Fate", "Mage"), + (29, "Twitch", "Marksman"), + (77, "Udyr", "Fighter"), + (6, "Urgot", "Fighter"), + (110, "Varus", "Marksman"), + (67, "Vayne", "Marksman"), + (45, "Veigar", "Mage"), + (161, "Vel'Koz", "Mage"), + (711, "Vex", "Mage"), + (254, "Vi", "Fighter"), + (234, "Viego", "Assassin"), + (112, "Viktor", "Mage"), + (8, "Vladimir", "Mage"), + (106, "Volibear", "Fighter"), + (19, "Warwick", "Fighter"), + (62, "Wukong", "Fighter"), + (498, "Xayah", "Marksman"), + (101, "Xerath", "Mage"), + (5, "Xin Zhao", "Fighter"), + (157, "Yasuo", "Fighter"), + (777, "Yone", "Assassin"), + (83, "Yorick", "Fighter"), + (350, "Yuumi", "Support"), + (154, "Zac", "Tank"), + (238, "Zed", "Assassin"), + (115, "Ziggs", "Mage"), + (26, "Zilean", "Support"), + (142, "Zoe", "Mage"), + (143, "Zyra", "Mage"), +] + + +async def seed(): + print("--- Seeding database ---") + + async with engine.begin() as conn: + print("Dropping and recreating tables...") + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + + async with AsyncSession(engine) as session: + + # --- Champions --- + print(f"Inserting {len(CHAMPIONS)} champions...") + session.add_all( + [ + Champions(champion_id=cid, name=name, tags=tags) + for cid, name, tags in CHAMPIONS + ] + ) + + # --- Users --- + from app.auth.passwords import hash_password + + seed_password = os.getenv("SEED_DEV_PASSWORD") + if not seed_password: + raise RuntimeError( + "Set SEED_DEV_PASSWORD in the environment before running seed (see .env.example)" + ) + + users = [ + Users( + id="00000000-0000-4000-8000-000000000001", + email="testuser1@vantagepoint.dev", + password_hash=hash_password(seed_password), + display_name="TestUser1", + created_at=datetime.now(timezone.utc).replace(tzinfo=None), + ), + Users( + id="00000000-0000-4000-8000-000000000002", + email="testuser2@vantagepoint.dev", + password_hash=hash_password(seed_password), + display_name="TestUser2", + created_at=datetime.now(timezone.utc).replace(tzinfo=None), + ), + ] + session.add_all(users) + + # --- Game Accounts --- + # Viewer PUUID for seeded match history (seed-viewer-puuid) + game_accounts = [ + GameAccounts( + puuid=VIEWER_PUUID, + game="league_of_legends", + game_name="You", + tag_line="EUW", + account_level=100, + profile_matches_sampled=PROFILE_MATCHES_SAMPLED, + ), + GameAccounts( + puuid="FAKE-PUUID-LOL-00000000000000000000000000000000000000000000000002", + game="league_of_legends", + game_name="SilverBot", + tag_line="EUW", + account_level=42, + ), + ] + session.add_all(game_accounts) + + # --- User <-> Game Account links --- + session.add_all( + [ + UserGameAccounts( + user_id="00000000-0000-4000-8000-000000000001", + puuid=VIEWER_PUUID, + ), + UserGameAccounts( + user_id="00000000-0000-4000-8000-000000000002", + puuid="FAKE-PUUID-LOL-00000000000000000000000000000000000000000000000002", + ), + ] + ) + + # --- Matches (8 seeded games) --- + from app.database.seed_data.matches import GAME_VERSION, MAP_ID, QUEUE_ID + + matches = [ + Matches( + match_id=row.match_id, + game_version=GAME_VERSION, + game_duration=row.game_duration, + queue_id=QUEUE_ID, + game_creation=game_creation_for(row.played_on, row.match_id), + map_id=MAP_ID, + played_on=row.played_on, + detail_json=build_detail_json(row.match_id), + ) + for row in SEED_MATCHES + ] + session.add_all(matches) + + # --- Viewer participants (one per match for list + profile) --- + session.add_all( + [ + Participants( + match_id=vp.match_id, + puuid=VIEWER_PUUID, + champion_id=vp.champion_id, + win=vp.win, + kills=vp.kills, + deaths=vp.deaths, + assists=vp.assists, + individual_position=vp.individual_position, + team_id=vp.team_id, + cs=vp.cs, + gold_earned=vp.gold_earned, + damage_to_champions=vp.damage_to_champions, + vision_score=vp.vision_score, + kill_participation=vp.kill_participation, + riot_id_display="You#EUW", + items_json="[3031, 3006, 3046, 3035, 3036, 0, 3363]", + summoner_spells_json="[4, 14]", + ) + for vp in SEED_VIEWER_PARTICIPANTS + ] + ) + + await seed_profile_for_puuid(session, VIEWER_PUUID, set_matches_sampled=False) + + await session.commit() + + print("--- Seed complete ---") + print(f" Champions: {len(CHAMPIONS)}") + print(" Users: 2") + print(" Game accounts: 2") + print(" User-GA links: 3") + print(f" Matches: {len(SEED_MATCHES)}") + print(f" Participants: {len(SEED_VIEWER_PARTICIPANTS)} (viewer)") + print(f" Achievements: {len(SEED_USER_ACHIEVEMENTS)} (viewer)") + print(f" Featured games: {len(SEED_FEATURED_GAMES)} (viewer)") + print( + " Dev login: testuser1@vantagepoint.dev (password from SEED_DEV_PASSWORD)" + ) + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/backend/app/database/seed_data/__init__.py b/backend/app/database/seed_data/__init__.py new file mode 100644 index 00000000..bb12ae08 --- /dev/null +++ b/backend/app/database/seed_data/__init__.py @@ -0,0 +1,21 @@ +from app.database.seed_data.matches import ( + SEED_MATCHES, + SEED_VIEWER_PARTICIPANTS, + VIEWER_PUUID, +) +from app.database.seed_data.profile import ( + PROFILE_MATCHES_SAMPLED, + SEED_ACHIEVEMENT_DEFINITIONS, + SEED_FEATURED_GAMES, + SEED_USER_ACHIEVEMENTS, +) + +__all__ = [ + "SEED_MATCHES", + "SEED_VIEWER_PARTICIPANTS", + "VIEWER_PUUID", + "PROFILE_MATCHES_SAMPLED", + "SEED_ACHIEVEMENT_DEFINITIONS", + "SEED_USER_ACHIEVEMENTS", + "SEED_FEATURED_GAMES", +] diff --git a/backend/app/database/seed_data/matches.py b/backend/app/database/seed_data/matches.py new file mode 100644 index 00000000..9ac4492d --- /dev/null +++ b/backend/app/database/seed_data/matches.py @@ -0,0 +1,556 @@ +"""Seed payloads for dev match history and detail scoreboards.""" + +import json +from dataclasses import dataclass +from datetime import date +from typing import Any + +VIEWER_PUUID = "seed-viewer-puuid" +VIEWER_RIOT_ID = "You#EUW" + +GAME_VERSION = "14.24.1" +QUEUE_ID = 420 +MAP_ID = 11 + +POSITION_ORDER = ("TOP", "JUNGLE", "MIDDLE", "BOTTOM", "UTILITY") + +_PLAYED_EPOCH = { + "2025-04-19": 1_745_107_200_000, + "2025-04-18": 1_745_020_800_000, +} + +_MATCH_EPOCH_OFFSET: dict[str, int] = { + "EUW1_700000001": 0, + "EUW1_700000002": 1, + "EUW1_700000003": 2, + "EUW1_700000004": 3, + "EUW1_700000005": 4, + "EUW1_700000006": 5, + "EUW1_700000007": 6, + "EUW1_700000008": 7, +} + +CHAMPION_ID_TO_NAME: dict[int, str] = { + 51: "Caitlyn", + 64: "Lee Sin", + 86: "Garen", + 99: "Lux", + 103: "Ahri", + 122: "Darius", + 134: "Syndra", + 157: "Yasuo", + 222: "Jinx", + 234: "Viego", + 238: "Zed", + 412: "Thresh", + 89: "Leona", + 54: "Malphite", + 111: "Nautilus", + 267: "Nami", + 21: "Miss Fortune", + 777: "Yone", +} + +ITEM_BUILDS: dict[str, list[int]] = { + "adc": [3031, 3006, 3046, 3035, 3036, 0, 3363], + "ap": [3089, 3020, 3135, 3157, 0, 0, 3364], + "bruiser": [3078, 3053, 6333, 3065, 0, 0, 3340], + "tank": [3068, 3075, 3742, 3111, 0, 0, 3340], + "jungle": [6692, 3153, 3044, 3814, 0, 0, 3364], + "support": [3190, 3107, 3222, 2055, 3869, 0, 3364], +} + +SPELLS_DEFAULT: tuple[int, ...] = (4, 14) +SPELLS_JUNGLE: tuple[int, ...] = (4, 11) + + +@dataclass(frozen=True) +class SeedMatchRow: + match_id: str + game_duration: int + played_on: date + + +@dataclass(frozen=True) +class SeedViewerParticipant: + match_id: str + champion_id: int + win: bool + kills: int + deaths: int + assists: int + cs: int + individual_position: str + team_id: int + gold_earned: int + damage_to_champions: int + vision_score: int + kill_participation: float + + +@dataclass(frozen=True) +class BotSlot: + champion_id: int + position: str + kills: int + deaths: int + assists: int + cs: int + gold_earned: int + damage_to_champions: int + vision_score: int + item_key: str = "bruiser" + summoner_spells: tuple[int, ...] = SPELLS_DEFAULT + + +@dataclass(frozen=True) +class MatchBotRoster: + allies: tuple[BotSlot, BotSlot, BotSlot, BotSlot] + enemies: tuple[BotSlot, BotSlot, BotSlot, BotSlot, BotSlot] + ally_bans: tuple[int, ...] + enemy_bans: tuple[int, ...] + + +def _bot( + champion_id: int, + position: str, + kills: int, + deaths: int, + assists: int, + cs: int, + gold: int, + damage: int, + vision: int = 25, + item_key: str = "bruiser", + spells: tuple[int, ...] = SPELLS_DEFAULT, +) -> BotSlot: + return BotSlot( + champion_id=champion_id, + position=position, + kills=kills, + deaths=deaths, + assists=assists, + cs=cs, + gold_earned=gold, + damage_to_champions=damage, + vision_score=vision, + item_key=item_key, + summoner_spells=spells, + ) + + +# Four ally bots + five enemies per match (viewer fills the 5th ally slot). +SEED_MATCH_BOT_ROSTERS: dict[str, MatchBotRoster] = { + "EUW1_700000001": MatchBotRoster( + allies=( + _bot(122, "TOP", 2, 9, 3, 142, 9800, 11200, 22, "tank"), + _bot(234, "JUNGLE", 5, 7, 8, 118, 9200, 12800, 38, "jungle", SPELLS_JUNGLE), + _bot(103, "MIDDLE", 7, 6, 4, 188, 10800, 19500, 28, "ap"), + _bot(412, "UTILITY", 0, 9, 16, 24, 7600, 4200, 72, "support"), + ), + enemies=( + _bot(86, "TOP", 6, 3, 5, 176, 11200, 14800, 24, "bruiser"), + _bot( + 64, "JUNGLE", 3, 4, 14, 128, 10100, 13200, 42, "jungle", SPELLS_JUNGLE + ), + _bot(134, "MIDDLE", 9, 4, 6, 201, 11800, 21000, 30, "ap"), + _bot(51, "BOTTOM", 10, 5, 4, 232, 13200, 24500, 26, "adc"), + _bot(99, "UTILITY", 1, 6, 19, 30, 7800, 5100, 68, "support"), + ), + ally_bans=(157, 64, 238, 555, 89), + enemy_bans=(222, 51, 134, 777, 360), + ), + "EUW1_700000002": MatchBotRoster( + allies=( + _bot(86, "TOP", 4, 2, 7, 184, 10900, 16200, 22, "bruiser"), + _bot( + 64, "JUNGLE", 6, 4, 11, 134, 10400, 13800, 44, "jungle", SPELLS_JUNGLE + ), + _bot(222, "BOTTOM", 9, 3, 5, 228, 12500, 23800, 24, "adc"), + _bot(89, "UTILITY", 1, 5, 20, 28, 8200, 4800, 76, "support"), + ), + enemies=( + _bot(122, "TOP", 5, 6, 2, 158, 10200, 14500, 20, "bruiser"), + _bot(234, "JUNGLE", 4, 8, 6, 112, 9400, 12100, 35, "jungle", SPELLS_JUNGLE), + _bot(134, "MIDDLE", 6, 5, 8, 192, 11100, 18800, 27, "ap"), + _bot(51, "BOTTOM", 7, 6, 3, 205, 11600, 20100, 25, "adc"), + _bot(267, "UTILITY", 2, 7, 12, 32, 7400, 3900, 62, "support"), + ), + ally_bans=(157, 238, 555, 360, 89), + enemy_bans=(103, 222, 64, 777, 134), + ), + "EUW1_700000003": MatchBotRoster( + allies=( + _bot(64, "JUNGLE", 7, 3, 9, 148, 10800, 15200, 46, "jungle", SPELLS_JUNGLE), + _bot(103, "MIDDLE", 6, 4, 10, 198, 11400, 20500, 32, "ap"), + _bot(222, "BOTTOM", 11, 2, 6, 242, 13800, 25200, 28, "adc"), + _bot(412, "UTILITY", 2, 4, 22, 36, 8600, 5200, 82, "support"), + ), + enemies=( + _bot(54, "TOP", 3, 8, 4, 142, 9800, 11800, 18, "tank"), + _bot(234, "JUNGLE", 5, 7, 5, 108, 9600, 12500, 36, "jungle", SPELLS_JUNGLE), + _bot(134, "MIDDLE", 8, 6, 4, 178, 10500, 17200, 26, "ap"), + _bot(21, "BOTTOM", 9, 5, 2, 198, 11200, 19800, 22, "adc"), + _bot(99, "UTILITY", 0, 9, 11, 26, 7200, 3600, 58, "support"), + ), + ally_bans=(122, 51, 238, 777, 267), + enemy_bans=(86, 222, 64, 555, 134), + ), + "EUW1_700000004": MatchBotRoster( + allies=( + _bot(86, "TOP", 3, 6, 4, 168, 10100, 13800, 20, "bruiser"), + _bot(103, "MIDDLE", 5, 7, 9, 176, 10600, 17800, 29, "ap"), + _bot(222, "BOTTOM", 8, 6, 2, 198, 11800, 21200, 23, "adc"), + _bot(111, "UTILITY", 1, 8, 14, 22, 7400, 4100, 70, "support"), + ), + enemies=( + _bot(122, "TOP", 7, 2, 3, 172, 11400, 16800, 24, "bruiser"), + _bot( + 234, "JUNGLE", 9, 4, 5, 124, 11200, 14200, 40, "jungle", SPELLS_JUNGLE + ), + _bot(134, "MIDDLE", 10, 3, 8, 188, 12000, 22400, 31, "ap"), + _bot(51, "BOTTOM", 12, 2, 4, 218, 12800, 24800, 27, "adc"), + _bot(267, "UTILITY", 2, 5, 16, 34, 8000, 5400, 74, "support"), + ), + ally_bans=(157, 51, 777, 360, 89), + enemy_bans=(64, 222, 238, 555, 134), + ), + "EUW1_700000005": MatchBotRoster( + allies=( + _bot(122, "TOP", 4, 5, 2, 152, 10400, 14200, 21, "bruiser"), + _bot(234, "JUNGLE", 6, 6, 7, 116, 9800, 13100, 39, "jungle", SPELLS_JUNGLE), + _bot(134, "MIDDLE", 8, 4, 9, 194, 11600, 20800, 30, "ap"), + _bot(267, "UTILITY", 2, 6, 17, 30, 7900, 4600, 71, "support"), + ), + enemies=( + _bot(86, "TOP", 5, 4, 6, 180, 11000, 15500, 23, "bruiser"), + _bot( + 64, "JUNGLE", 2, 5, 13, 136, 10200, 12800, 41, "jungle", SPELLS_JUNGLE + ), + _bot(103, "MIDDLE", 7, 5, 5, 202, 11300, 19200, 28, "ap"), + _bot(222, "BOTTOM", 6, 8, 4, 186, 10800, 18500, 24, "adc"), + _bot(412, "UTILITY", 1, 7, 15, 28, 7600, 4300, 66, "support"), + ), + ally_bans=(222, 157, 64, 89, 777), + enemy_bans=(51, 103, 238, 555, 360), + ), + "EUW1_700000006": MatchBotRoster( + allies=( + _bot(234, "JUNGLE", 4, 8, 4, 108, 9200, 11800, 37, "jungle", SPELLS_JUNGLE), + _bot(134, "MIDDLE", 6, 6, 3, 168, 10400, 17500, 27, "ap"), + _bot(51, "BOTTOM", 5, 7, 1, 174, 10000, 16800, 22, "adc"), + _bot(99, "UTILITY", 0, 10, 9, 22, 7100, 3800, 64, "support"), + ), + enemies=( + _bot(86, "TOP", 8, 2, 4, 188, 11800, 17200, 25, "bruiser"), + _bot( + 64, "JUNGLE", 7, 3, 10, 140, 11000, 14500, 43, "jungle", SPELLS_JUNGLE + ), + _bot(103, "MIDDLE", 11, 2, 6, 212, 12200, 23200, 33, "ap"), + _bot(222, "BOTTOM", 9, 4, 5, 226, 12600, 24100, 26, "adc"), + _bot(412, "UTILITY", 2, 5, 18, 34, 8400, 4900, 77, "support"), + ), + ally_bans=(86, 103, 64, 267, 777), + enemy_bans=(122, 222, 51, 555, 134), + ), + "EUW1_700000007": MatchBotRoster( + allies=( + _bot(86, "TOP", 6, 3, 5, 192, 11500, 16800, 24, "bruiser"), + _bot( + 64, "JUNGLE", 5, 4, 12, 138, 10700, 14100, 45, "jungle", SPELLS_JUNGLE + ), + _bot(222, "BOTTOM", 10, 2, 8, 236, 13200, 24600, 27, "adc"), + _bot(89, "UTILITY", 1, 4, 21, 32, 8800, 5000, 80, "support"), + ), + enemies=( + _bot(122, "TOP", 4, 7, 2, 148, 10100, 13200, 19, "bruiser"), + _bot(234, "JUNGLE", 6, 8, 4, 114, 9500, 12200, 38, "jungle", SPELLS_JUNGLE), + _bot(103, "MIDDLE", 5, 6, 7, 184, 10800, 18100, 29, "ap"), + _bot(51, "BOTTOM", 8, 5, 3, 208, 11400, 20500, 25, "adc"), + _bot(111, "UTILITY", 2, 8, 11, 26, 7300, 3700, 61, "support"), + ), + ally_bans=(134, 157, 238, 360, 89), + enemy_bans=(103, 222, 64, 777, 51), + ), + "EUW1_700000008": MatchBotRoster( + allies=( + _bot(122, "TOP", 3, 8, 1, 138, 9600, 12500, 20, "bruiser"), + _bot(234, "JUNGLE", 5, 9, 6, 102, 9000, 11200, 36, "jungle", SPELLS_JUNGLE), + _bot(103, "MIDDLE", 4, 7, 5, 162, 9800, 15800, 26, "ap"), + _bot(51, "BOTTOM", 7, 5, 3, 188, 10400, 19200, 23, "adc"), + ), + enemies=( + _bot(86, "TOP", 7, 2, 6, 182, 11200, 15900, 22, "bruiser"), + _bot(64, "JUNGLE", 8, 3, 9, 146, 11400, 15200, 44, "jungle", SPELLS_JUNGLE), + _bot(134, "MIDDLE", 10, 1, 8, 198, 11800, 21800, 30, "ap"), + _bot(222, "BOTTOM", 11, 2, 4, 224, 12800, 25200, 25, "adc"), + _bot(267, "UTILITY", 2, 4, 17, 36, 8200, 4800, 69, "support"), + ), + ally_bans=(412, 157, 64, 555, 777), + enemy_bans=(103, 222, 51, 89, 134), + ), +} + +_VIEWER_BY_MATCH: dict[str, SeedViewerParticipant] = {} + + +def _champion_name(champion_id: int) -> str: + return CHAMPION_ID_TO_NAME.get(champion_id, f"Champion{champion_id}") + + +def _participant_from_bot(bot: BotSlot, team_win: bool) -> dict[str, Any]: + name = _champion_name(bot.champion_id) + return { + "puuid": f"puuid-{bot.champion_id}-{bot.position}", + "riot_id": f"{name.replace(' ', '')}Bot#EUW", + "champion_id": bot.champion_id, + "champion_name": name, + "position": bot.position, + "win": team_win, + "kills": bot.kills, + "deaths": bot.deaths, + "assists": bot.assists, + "cs": bot.cs, + "gold_earned": bot.gold_earned, + "damage_to_champions": bot.damage_to_champions, + "vision_score": bot.vision_score, + "items": ITEM_BUILDS.get(bot.item_key, ITEM_BUILDS["bruiser"]), + "summoner_spells": list(bot.summoner_spells), + "is_viewer": False, + } + + +def _participant_from_viewer( + viewer: SeedViewerParticipant, team_win: bool +) -> dict[str, Any]: + name = _champion_name(viewer.champion_id) + item_key = { + "BOTTOM": "adc", + "MIDDLE": "ap", + "TOP": "bruiser", + "JUNGLE": "jungle", + "UTILITY": "support", + }.get(viewer.individual_position, "bruiser") + spells = SPELLS_JUNGLE if viewer.individual_position == "JUNGLE" else SPELLS_DEFAULT + return { + "puuid": VIEWER_PUUID, + "riot_id": VIEWER_RIOT_ID, + "champion_id": viewer.champion_id, + "champion_name": name, + "position": viewer.individual_position, + "win": team_win, + "kills": viewer.kills, + "deaths": viewer.deaths, + "assists": viewer.assists, + "cs": viewer.cs, + "gold_earned": viewer.gold_earned, + "damage_to_champions": viewer.damage_to_champions, + "vision_score": viewer.vision_score, + "items": ITEM_BUILDS[item_key], + "summoner_spells": list(spells), + "is_viewer": True, + } + + +def _slot_for_position(slots: tuple[BotSlot, ...], position: str) -> BotSlot | None: + for slot in slots: + if slot.position == position: + return slot + return None + + +def _build_team_participants( + viewer: SeedViewerParticipant, + bots: tuple[BotSlot, ...], + team_id: int, + team_win: bool, +) -> list[dict[str, Any]]: + participants: list[dict[str, Any]] = [] + for position in POSITION_ORDER: + if team_id == viewer.team_id and position == viewer.individual_position: + participants.append(_participant_from_viewer(viewer, team_win)) + else: + bot = _slot_for_position(bots, position) + if bot: + participants.append(_participant_from_bot(bot, team_win)) + return participants + + +def _objectives_for_win(team_won: bool) -> dict[str, int]: + return { + "baron": int(team_won), + "dragon": 3 if team_won else 2, + "rift_herald": int(team_won), + "tower": 9 if team_won else 4, + "inhibitor": 2 if team_won else 0, + } + + +def _team_payload( + team_id: int, + team_won: bool, + bans: tuple[int, ...], + bots: tuple[BotSlot, ...], + viewer: SeedViewerParticipant, +) -> dict[str, Any]: + return { + "team_id": team_id, + "win": team_won, + "bans": list(bans), + "objectives": _objectives_for_win(team_won), + "participants": _build_team_participants(viewer, bots, team_id, team_won), + } + + +def _build_teams_for_match( + viewer: SeedViewerParticipant, roster: MatchBotRoster +) -> list[dict[str, Any]]: + ally_win = viewer.win + enemy_win = not ally_win + + blue_win = ally_win if viewer.team_id == 100 else enemy_win + red_win = ally_win if viewer.team_id == 200 else enemy_win + + blue_bots: tuple[BotSlot, ...] + red_bots: tuple[BotSlot, ...] + blue_bans: tuple[int, ...] + red_bans: tuple[int, ...] + if viewer.team_id == 100: + blue_bots, red_bots = roster.allies, roster.enemies + blue_bans, red_bans = roster.ally_bans, roster.enemy_bans + else: + blue_bots, red_bots = roster.enemies, roster.allies + blue_bans, red_bans = roster.enemy_bans, roster.ally_bans + + return [ + _team_payload(100, blue_win, blue_bans, blue_bots, viewer), + _team_payload(200, red_win, red_bans, red_bots, viewer), + ] + + +def build_detail_json(match_id: str) -> str: + viewer = _VIEWER_BY_MATCH[match_id] + roster = SEED_MATCH_BOT_ROSTERS[match_id] + teams = _build_teams_for_match(viewer, roster) + return json.dumps({"teams": teams, "match_id": match_id}) + + +SEED_MATCHES: list[SeedMatchRow] = [ + SeedMatchRow("EUW1_700000001", 25 * 60, date(2025, 4, 19)), + SeedMatchRow("EUW1_700000002", 30 * 60, date(2025, 4, 19)), + SeedMatchRow("EUW1_700000003", 40 * 60, date(2025, 4, 19)), + SeedMatchRow("EUW1_700000004", 20 * 60, date(2025, 4, 19)), + SeedMatchRow("EUW1_700000005", 28 * 60, date(2025, 4, 19)), + SeedMatchRow("EUW1_700000006", 22 * 60, date(2025, 4, 19)), + SeedMatchRow("EUW1_700000007", 35 * 60, date(2025, 4, 18)), + SeedMatchRow("EUW1_700000008", 18 * 60, date(2025, 4, 18)), +] + +SEED_VIEWER_PARTICIPANTS: list[SeedViewerParticipant] = [ + SeedViewerParticipant( + "EUW1_700000001", + 222, + False, + 4, + 8, + 6, + 165, + "BOTTOM", + 200, + 10_500, + 16_000, + 22, + 0.55, + ), + SeedViewerParticipant( + "EUW1_700000002", + 103, + True, + 8, + 3, + 6, + 210, + "MIDDLE", + 100, + 12_000, + 22_000, + 35, + 0.72, + ), + SeedViewerParticipant( + "EUW1_700000003", 86, True, 5, 2, 4, 198, "TOP", 100, 11_500, 19_000, 28, 0.65 + ), + SeedViewerParticipant( + "EUW1_700000004", + 64, + False, + 4, + 5, + 12, + 142, + "JUNGLE", + 100, + 9_800, + 14_000, + 40, + 0.80, + ), + SeedViewerParticipant( + "EUW1_700000005", + 51, + True, + 11, + 2, + 5, + 224, + "BOTTOM", + 200, + 14_000, + 28_000, + 30, + 0.70, + ), + SeedViewerParticipant( + "EUW1_700000006", 122, False, 3, 7, 2, 156, "TOP", 200, 9_000, 15_000, 18, 0.45 + ), + SeedViewerParticipant( + "EUW1_700000007", + 134, + True, + 9, + 4, + 7, + 205, + "MIDDLE", + 100, + 13_500, + 25_000, + 32, + 0.68, + ), + SeedViewerParticipant( + "EUW1_700000008", + 412, + False, + 1, + 6, + 14, + 28, + "UTILITY", + 200, + 7_500, + 8_000, + 78, + 0.85, + ), +] + +_VIEWER_BY_MATCH.update({v.match_id: v for v in SEED_VIEWER_PARTICIPANTS}) + + +def game_creation_for(played_on: date, match_id: str | None = None) -> int: + key = played_on.isoformat() + base = _PLAYED_EPOCH.get(key, _PLAYED_EPOCH["2025-04-19"]) + if match_id and match_id in _MATCH_EPOCH_OFFSET: + return base + _MATCH_EPOCH_OFFSET[match_id] * 60_000 + return base diff --git a/backend/app/database/seed_data/profile.py b/backend/app/database/seed_data/profile.py new file mode 100644 index 00000000..f4c43cc2 --- /dev/null +++ b/backend/app/database/seed_data/profile.py @@ -0,0 +1,114 @@ +"""Seed payloads for profile achievements and featured-game banners.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SeedAchievementDefinition: + id: str + label: str + description: str + source_field: str + + +@dataclass(frozen=True) +class SeedUserAchievement: + achievement_id: str + count: int + + +@dataclass(frozen=True) +class SeedFeaturedGame: + sort_order: int + game_name: str + cover_image_key: str + card_image_key: str | None + efficiency_score: int + time_spent_seconds: int + wins: int + losses: int + average_kda: float + + +PROFILE_MATCHES_SAMPLED = 20 + +SEED_ACHIEVEMENT_DEFINITIONS: list[SeedAchievementDefinition] = [ + SeedAchievementDefinition( + "triple-kill", + "Triple", + "Triple kills across sampled matches", + "challenges.tripleKills", + ), + SeedAchievementDefinition( + "first-blood", + "First Blood", + "First blood kills", + "challenges.firstBloodKill", + ), + SeedAchievementDefinition( + "killing-spree", + "Spree", + "Killing sprees of 3+", + "challenges.killingSprees", + ), + SeedAchievementDefinition( + "high-kp", + "Team Fight", + "Matches with 70%+ kill participation", + "challenges.killParticipation", + ), + SeedAchievementDefinition( + "vision", + "Ward King", + "Top vision score on team", + "challenges.visionScorePerMinute", + ), + SeedAchievementDefinition( + "damage", + "Carry", + "Highest damage to champions on team", + "challenges.teamDamagePercentage", + ), + SeedAchievementDefinition( + "turrets", + "Siege", + "Turret takedowns", + "challenges.turretTakedowns", + ), +] + +SEED_USER_ACHIEVEMENTS: list[SeedUserAchievement] = [ + SeedUserAchievement("triple-kill", 4), + SeedUserAchievement("first-blood", 3), + SeedUserAchievement("killing-spree", 12), + SeedUserAchievement("high-kp", 9), + SeedUserAchievement("vision", 6), + SeedUserAchievement("damage", 7), + SeedUserAchievement("turrets", 18), +] + +# 1:04:34:23 and 0:42:18:05 (D:HH:MM:SS) +SEED_FEATURED_GAMES: list[SeedFeaturedGame] = [ + SeedFeaturedGame( + sort_order=0, + game_name="League Of Legends", + cover_image_key="league_wild_rift_cover", + card_image_key="league_wild_rift_card", + efficiency_score=115, + time_spent_seconds=1 * 86400 + 4 * 3600 + 34 * 60 + 23, + wins=13, + losses=7, + average_kda=3.8, + ), + SeedFeaturedGame( + sort_order=1, + game_name="League Of Legends", + cover_image_key="league_wild_rift_cover", + card_image_key="league_wild_rift_card", + efficiency_score=98, + time_spent_seconds=42 * 3600 + 18 * 60 + 5, + wins=7, + losses=5, + average_kda=3.2, + ), +] diff --git a/backend/app/database/seed_profile.py b/backend/app/database/seed_profile.py new file mode 100644 index 00000000..4ee2b628 --- /dev/null +++ b/backend/app/database/seed_profile.py @@ -0,0 +1,74 @@ +"""Shared profile seed inserts for main seed and test fixtures.""" + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import ( + AchievementDefinitions, + GameAccounts, + UserAchievements, + UserFeaturedGames, +) +from app.database.seed_data.profile import ( + PROFILE_MATCHES_SAMPLED, + SEED_ACHIEVEMENT_DEFINITIONS, + SEED_FEATURED_GAMES, + SEED_USER_ACHIEVEMENTS, +) + + +async def seed_profile_for_puuid( + session: AsyncSession, + puuid: str, + *, + set_matches_sampled: bool = True, +) -> None: + session.add_all( + [ + AchievementDefinitions( + id=d.id, + label=d.label, + description=d.description, + source_field=d.source_field, + ) + for d in SEED_ACHIEVEMENT_DEFINITIONS + ] + ) + + session.add_all( + [ + UserAchievements( + puuid=puuid, + achievement_id=ua.achievement_id, + count=ua.count, + ) + for ua in SEED_USER_ACHIEVEMENTS + ] + ) + + session.add_all( + [ + UserFeaturedGames( + puuid=puuid, + sort_order=fg.sort_order, + game_name=fg.game_name, + cover_image_key=fg.cover_image_key, + card_image_key=fg.card_image_key, + efficiency_score=fg.efficiency_score, + time_spent_seconds=fg.time_spent_seconds, + wins=fg.wins, + losses=fg.losses, + average_kda=fg.average_kda, + ) + for fg in SEED_FEATURED_GAMES + ] + ) + + if set_matches_sampled: + from sqlmodel import select + + result = await session.execute( + select(GameAccounts).where(GameAccounts.puuid == puuid) + ) + account = result.scalar_one_or_none() + if account: + account.profile_matches_sampled = PROFILE_MATCHES_SAMPLED diff --git a/backend/app/database/session.py b/backend/app/database/session.py new file mode 100644 index 00000000..c8d9c4ae --- /dev/null +++ b/backend/app/database/session.py @@ -0,0 +1,29 @@ +import os +from collections.abc import AsyncGenerator + +from dotenv import load_dotenv +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel + +load_dotenv() + +DATABASE_URL = os.getenv("DATABASE_URL") +if not DATABASE_URL: + raise RuntimeError( + "DATABASE_URL environment variable must be set (see backend/.env.example)" + ) + +engine = create_async_engine(DATABASE_URL) +async_session_maker = async_sessionmaker(engine, expire_on_commit=False) + + +async def init_db() -> None: + from app.database import models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_maker() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py index 27422338..c8c4d076 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,77 +1,160 @@ -import os -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from contextlib import asynccontextmanager +from typing import Any, Dict + from fastapi.middleware.cors import CORSMiddleware -from sqlmodel import SQLModel, select -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from sqlmodel import select +from starlette.exceptions import HTTPException as StarletteHTTPException from dotenv import load_dotenv +from app.api.middleware import ProcessTimeMiddleware +from app.api.routes import router +from app.database.models import GameAccounts +from app.database.session import async_session_maker, init_db +from app.schemas.generic_schemas import get_error_reason +from app.services.riot_api import get_puuid_by_riot_id + # from typing import List, Optional # above commit commited out as import not used but will be used later # (Make sure riot_api.py is in backend/app/services/) # (make sure models.py is in backend/app/database/ ) -from app.database.models import Summoners -from app.services.riot_api import get_puuid_by_riot_id load_dotenv() - # DATABASE & APP SETUP # (Neo: Database models are now in a separate file to keep main.py cleaner. See models.py for details and comments on the database structure.) -app = FastAPI(title="Vantage Point Backend") +# from slowapi import _rate_limit_exceeded_handler +# from slowapi.errors import RateLimitExceeded +# from slowapi.middleware import SlowAPIMiddleware -# Get the URL from the docker-compose environment variable -# points to the db service not localhost hopfully, this should only work inside the container. -DATABASE_URL = os.getenv("DATABASE_URL") -if not DATABASE_URL: +# limiter = Limiter(key_func=get_remote_address) - print( - "DATABASE_URL not set. Using default local Postgres URL for development/ Testing." - ) - DATABASE_URL = ( - "postgresql+asyncpg://postgres:password@localhost:5432/vantage_point_db" - ) -engine = create_async_engine(DATABASE_URL) +@asynccontextmanager +async def lifespan(app: FastAPI): + try: + await init_db() + except Exception as exc: + print(f"Database initialization skipped: {exc}") + yield + + +app = FastAPI( + title="Vantage Point Backend", + description=( + "API for authentication, profile management, Riot match data, and spatial " + "intelligence features." + ), + version="0.1.0", + lifespan=lifespan, +) + +# app.state.limiter = limiter +# app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +# app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # type: ignore[arg-type] +# app.add_middleware(SlowAPIMiddleware) # CORS for frontend # 3000 = React default, 5173 = Vite default. app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://localhost:5173"], + allow_origin_regex=".*", allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["X-Process-Time"], ) +app.add_middleware(ProcessTimeMiddleware) -# STARTUP +app.include_router(router, prefix="/api") -@app.on_event("startup") -async def on_startup(): - # Creates any tables that don't exist yet. Safe to run on every boot - # It won't touch tables that are already there. - async with engine.begin() as conn: - await conn.run_sync(SQLModel.metadata.create_all) - print("Tables are ready.") +def error_response(status_code: int, detail: Any) -> dict[str, Any]: + return { + "status": "error", + "error_number": status_code, + "reason": get_error_reason(status_code), + "detail": detail, + } -# ROUTES +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler( + request: Request, exc: StarletteHTTPException +) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=error_response(exc.status_code, exc.detail), + headers=exc.headers, + ) -@app.get("/") -async def root(): - return {"message": "Vantage Point API running"} +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + return JSONResponse( + status_code=400, + content=error_response(400, exc.errors()), + ) -@app.get("/health") -async def health(): - return {"status": "Vantage Point Backend running healthy"} +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=500, + content=error_response(500, "Unexpected server error"), + ) -@app.post("/api/test") -async def test_endpoint(data: dict): +class RootResponse(BaseModel): + status: str = Field(..., description="Current backend status") + message: str = Field(..., description="API status message") + + +class HealthResponse(BaseModel): + status: str = Field(..., description="Current backend health status") + + +class TestResponse(BaseModel): + received: Dict[str, Any] + message: str + + +@app.get( + "/", + tags=["System"], + summary="API root", + description="Returns a simple message confirming that the backend is running.", +) +async def get_root() -> RootResponse: + # Explicitly call your schema class + return RootResponse(status="success", message="Welcome to Vantage Point API") + + +@app.get( + "/health", + tags=["System"], + summary="Health check", + description="Reports whether the backend service is healthy.", +) +async def health() -> HealthResponse: + return HealthResponse(status="Vantage Point Backend running healthy") + + +@app.post( + "/api/test", + tags=["System"], + summary="Echo test payload", + description="Accepts any JSON object and echoes it back for quick API testing.", + response_model=TestResponse, +) +async def test_endpoint(data: Dict[str, Any]) -> Dict[str, Any]: print(f"Test endpoint called with data: {data}") return {"received": data, "message": "Test successful"} @@ -87,25 +170,26 @@ async def register_summoner(game_name: str, tag_line: str): return {"error": "Could not find player on Riot servers."} # 2. Save to Database; should only do so if this player is not in the DB already - async with AsyncSession(engine) as session: - statement = select(Summoners).where(Summoners.puuid == puuid) + async with async_session_maker() as session: + statement = select(GameAccounts).where(GameAccounts.puuid == puuid) result = await session.execute(statement) - existing_summoner = result.scalar_one_or_none() + existing_account = result.scalar_one_or_none() # adding this check just to be safe and security even if no exist is already below it - if existing_summoner: + if existing_account: return {"message": "Summoner already in database."} - if not existing_summoner: - new_summoner = Summoners( - puuid=puuid, game_name=game_name, tag_line=tag_line, summoner_level=0 - ) - session.add(new_summoner) - await session.commit() - return { - "message": f"Successfully registered {game_name}#{tag_line}", - "puuid": puuid, - } - - # should not be reached as the check i added earlier should catch this but just in case, - return {"message": "Summoner already in database."} + new_account = GameAccounts( + puuid=puuid, + game="league_of_legends", + game_name=game_name, + tag_line=tag_line, + summoner_level=0, + ) + session.add(new_account) + await session.commit() + + return { + "message": f"Successfully registered {game_name}#{tag_line}", + "puuid": puuid, + } diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 00000000..b24d51db --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,92 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from app.auth.jwt import create_access_token, create_refresh_token, verify_refresh_token +from app.auth.passwords import hash_password, verify_password +from app.database.models import Users +from app.database.session import get_session +from app.schemas.auth import ( + LoginRequest, + RefreshRequest, + RegisterRequest, + TokenResponse, +) + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + + +@router.post("/register", response_model=TokenResponse) +async def register( + body: RegisterRequest, + session: AsyncSession = Depends(get_session), +): + existing = await session.execute(select(Users).where(Users.email == body.email)) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Email already registered", + ) + + user_id = str(uuid.uuid4()) + user = Users( + id=user_id, + email=body.email, + display_name=body.display_name.strip(), + password_hash=hash_password(body.password), + ) + session.add(user) + await session.commit() + + return TokenResponse( + access_token=create_access_token(user_id), + refresh_token=create_refresh_token(user_id), + ) + + +@router.post("/login", response_model=TokenResponse) +async def login( + body: LoginRequest, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(Users).where(Users.email == body.email)) + user = result.scalar_one_or_none() + if not user or not verify_password(body.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + ) + + user_id = user.id + return TokenResponse( + access_token=create_access_token(user_id), + refresh_token=create_refresh_token(user_id), + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh( + body: RefreshRequest, + session: AsyncSession = Depends(get_session), +): + user_id = verify_refresh_token(body.refresh_token) + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + result = await session.execute(select(Users).where(Users.id == user_id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + return TokenResponse( + access_token=create_access_token(user_id), + refresh_token=create_refresh_token(user_id), + ) diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py new file mode 100644 index 00000000..1cba51f9 --- /dev/null +++ b/backend/app/routers/matches.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.deps import get_current_user +from app.database.models import Users +from app.database.session import get_session +from app.schemas.match import MatchDetailResponse, MatchHistorySummaryResponse +from app.services.match_detail import get_match_detail, user_has_match_access +from app.services.match_history import list_match_history +from app.services.user_accounts import get_linked_puuids, get_primary_linked_puuid + +router = APIRouter(prefix="/api/v1/matches", tags=["matches"]) + + +@router.get("", response_model=list[MatchHistorySummaryResponse]) +async def get_matches( + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + puuid = await get_primary_linked_puuid(session, current_user.id) + if not puuid: + return [] + return await list_match_history(session, puuid) + + +@router.get("/{match_id}", response_model=MatchDetailResponse) +async def get_match_by_id( + match_id: str, + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + puuids = await get_linked_puuids(session, current_user.id) + if not await user_has_match_access(session, puuids, match_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Match not found", + ) + + viewer_puuid = await get_primary_linked_puuid(session, current_user.id) + detail = await get_match_detail(session, match_id, viewer_puuid) + if not detail: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Match not found", + ) + return detail diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 00000000..c722a770 --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,133 @@ +from fastapi import APIRouter, Depends, File, UploadFile, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.deps import get_current_user +from app.database.models import Users +from app.database.session import get_session +from app.schemas.profile import PlayerProfileResponse +from app.schemas.user import ( + AvatarUploadResponse, + LinkGameAccountRequest, + LinkGameAccountResponse, + UpdateUserMeRequest, + UserMeResponse, +) +from app.services.avatar_storage import delete_avatar_files, save_avatar +from app.services.player_profile import build_player_profile +from app.services.user_accounts import ( + get_primary_linked_account, + get_primary_linked_puuid, + link_riot_account_for_user, + riot_id_tag, +) + +router = APIRouter(prefix="/api/v1/users", tags=["users"]) + + +def _user_me_response(user: Users, account) -> UserMeResponse: + tag = riot_id_tag(account.game_name, account.tag_line) if account else None + return UserMeResponse( + id=user.id, + email=user.email, + display_name=user.display_name, + avatar_url=user.avatar_url, + riot_id_tag=tag, + has_linked_riot=account is not None, + ) + + +@router.get("/me", response_model=UserMeResponse) +async def get_me( + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + account = await get_primary_linked_account(session, current_user.id) + return _user_me_response(current_user, account) + + +@router.patch("/me", response_model=UserMeResponse) +async def update_me( + body: UpdateUserMeRequest, + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + current_user.display_name = body.display_name.strip() + session.add(current_user) + await session.commit() + await session.refresh(current_user) + account = await get_primary_linked_account(session, current_user.id) + return _user_me_response(current_user, account) + + +@router.post("/me/avatar", response_model=AvatarUploadResponse) +async def upload_avatar( + file: UploadFile = File(...), + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + avatar_path = await save_avatar(current_user.id, file) + current_user.avatar_url = avatar_path + session.add(current_user) + await session.commit() + return AvatarUploadResponse(avatar_url=avatar_path) + + +@router.delete("/me/avatar", status_code=status.HTTP_204_NO_CONTENT) +async def delete_avatar( + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + delete_avatar_files(current_user.id) + current_user.avatar_url = None + session.add(current_user) + await session.commit() + + +@router.get("/me/profile", response_model=PlayerProfileResponse) +async def get_my_profile( + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + account = await get_primary_linked_account(session, current_user.id) + riot_id_tag_value = ( + riot_id_tag(account.game_name, account.tag_line) if account else None + ) + puuid = await get_primary_linked_puuid(session, current_user.id) + return await build_player_profile(session, current_user, puuid, riot_id_tag_value) + + +async def _link_game_account_impl( + body: LinkGameAccountRequest, + current_user: Users, + session: AsyncSession, +) -> LinkGameAccountResponse: + puuid, tag = await link_riot_account_for_user( + session, + current_user.id, + riot_id=body.riot_id, + game_name=body.game_name, + tag_line=body.tag_line, + ) + return LinkGameAccountResponse( + puuid=puuid, + riot_id_tag=tag, + message=f"Successfully linked {tag}", + ) + + +@router.post("/me/game-accounts", response_model=LinkGameAccountResponse) +async def link_game_account( + body: LinkGameAccountRequest, + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + return await _link_game_account_impl(body, current_user, session) + + +@router.put("/me/game-accounts", response_model=LinkGameAccountResponse) +async def update_game_account( + body: LinkGameAccountRequest, + current_user: Users = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + return await _link_game_account_impl(body, current_user, session) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 00000000..235e4b77 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, EmailStr, Field + + +class RegisterRequest(BaseModel): + email: EmailStr + display_name: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=8, max_length=128) + + +class LoginRequest(BaseModel): + email: EmailStr + password: str + + +class RefreshRequest(BaseModel): + refresh_token: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" diff --git a/backend/app/schemas/auth_schemas.py b/backend/app/schemas/auth_schemas.py new file mode 100644 index 00000000..5b562b6c --- /dev/null +++ b/backend/app/schemas/auth_schemas.py @@ -0,0 +1,153 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Any, ClassVar, Optional, List +from datetime import datetime + +# ============ Request Models ============ + + +class UserRegister(BaseModel): + """User registration request""" + + username: str = Field( + ..., min_length=3, max_length=50, description="Unique username" + ) + email: EmailStr = Field(..., description="Valid email address") + password: str = Field(..., min_length=8, description="Password (min 8 characters)") + confirm_password: str = Field(..., min_length=8, description="Must match password") + + class Config: + json_schema_extra: ClassVar[dict[str, Any]] = { + "example": { + "username": "Sn1per1", + "email": "player@example.com", + "password": "securepassword123", + "confirm_password": "securepassword123", + } + } + + +class UserLogin(BaseModel): + """User login request""" + + username: str = Field(..., min_length=3, max_length=50, description="Your username") + password: str = Field(..., min_length=8, description="Your password") + + class Config: + json_schema_extra: ClassVar[dict[str, Any]] = { + "example": {"username": "Sn1per1", "password": "securepassword123"} + } + + +class UserConfirm(BaseModel): + """Email confirmation request (for 2FA/email verification)""" + + username: str = Field(..., description="Username to confirm") + confirmation_code: str = Field( + ..., min_length=6, max_length=6, description="6-digit confirmation code" + ) + + +class RefreshTokenRequest(BaseModel): + """Request new access token using refresh token""" + + refresh_token: str = Field(..., description="Refresh token from login") + + +class ChangePasswordRequest(BaseModel): + """Request to change password""" + + old_password: str = Field(..., min_length=8) + new_password: str = Field(..., min_length=8) + confirm_new_password: str = Field(..., min_length=8) + + +# ============ Response Models ============ + + +class UserInfo(BaseModel): + """User information response""" + + id: str + username: str + email: str + riot_puuid: Optional[str] = None + riot_game_name: Optional[str] = None + riot_tag_line: Optional[str] = None + is_active: bool = True + is_verified: bool = False + created_at: datetime + last_login: Optional[datetime] = None + + +class TokenResponse(BaseModel): + """Authentication token response""" + + access_token: str + refresh_token: str + id_token: Optional[str] = None # For future OIDC integration + token_type: str = "Bearer" + expires_in: int = 3600 # 1 hour in seconds + + class Config: + json_schema_extra: ClassVar[dict[str, Any]] = { + "example": { + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "Bearer", + "expires_in": 3600, + } + } + + +class LoginResponse(BaseModel): + """Complete login response""" + + user: UserInfo + tokens: TokenResponse + + +class RegisterResponse(BaseModel): + """Registration response""" + + message: str + user_id: str + username: str + email: str + requires_verification: bool = True + + +# ============ Token Payload Models ============ + + +class AccessTokenPayload(BaseModel): + """JWT Access Token Payload""" + + sub: str # user_id + username: str + exp: int # expiration timestamp + iat: int # issued at timestamp + type: str = "access" + + +class RefreshTokenPayload(BaseModel): + """JWT Refresh Token Payload""" + + sub: str # user_id + username: str + exp: int + iat: int + type: str = "refresh" + + +class PlayerSummary(BaseModel): + most_played_character: str + common_mistakes: List[str] + avg_kda: str + win_rate: str + + +class ProfileResponse(BaseModel): + uuid: str + username: str + total_matches: int + player_summary: PlayerSummary diff --git a/backend/app/schemas/generic_schemas.py b/backend/app/schemas/generic_schemas.py new file mode 100644 index 00000000..8672918f --- /dev/null +++ b/backend/app/schemas/generic_schemas.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, Field +from typing import Any, Optional + +ERROR_REASONS: dict[int, str] = { + 400: "Bad request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Data not found", + 405: "Method not allowed", + 415: "Unsupported media type", + 429: "Rate limit exceeded", + 500: "Internal server error", + 502: "Bad gateway", + 503: "Service unavailable", + 504: "Gateway timeout", +} + + +def get_error_reason(status_code: int) -> str: + return ERROR_REASONS.get(status_code, "Unexpected error") + + +class APIResponse(BaseModel): + status: str = "success" + message: Optional[str] = None + data: Optional[Any] = None + + +class ErrorResponse(BaseModel): + status: str = Field(default="error", description="Error response status") + error_number: int = Field(..., description="HTTP status code for the error") + reason: str = Field(..., description="Standard reason for the error number") + detail: Any = Field(..., description="Specific error details") diff --git a/backend/app/schemas/match.py b/backend/app/schemas/match.py new file mode 100644 index 00000000..366c28ca --- /dev/null +++ b/backend/app/schemas/match.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel + + +class ObjectivesSummaryResponse(BaseModel): + baron: int + dragon: int + rift_herald: int + tower: int + inhibitor: int + + +class ChampionBanResponse(BaseModel): + champion_id: int + champion_name: str + + +class ParticipantDetailResponse(BaseModel): + puuid: str + riot_id: str | None + champion_id: int + champion_name: str + position: str + win: bool + kills: int + deaths: int + assists: int + cs: int + gold_earned: int + damage_to_champions: int + vision_score: int + items: list[int] + summoner_spells: list[int] + is_viewer: bool + + +class TeamDetailResponse(BaseModel): + team_id: int + win: bool + bans: list[ChampionBanResponse] + objectives: ObjectivesSummaryResponse + participants: list[ParticipantDetailResponse] + + +class MatchDetailResponse(BaseModel): + match_id: str + game_creation: int + game_duration: int + game_version: str + queue_id: int + queue_label: str + map_id: int + map_label: str + teams: list[TeamDetailResponse] + + +class MatchHistorySummaryResponse(BaseModel): + match_id: str + champion_name: str + outcome: str + duration_minutes: int + map_label: str + played_on: str + kills: int + deaths: int + assists: int + cs: int + position: str diff --git a/backend/app/schemas/profile.py b/backend/app/schemas/profile.py new file mode 100644 index 00000000..75bcc7ae --- /dev/null +++ b/backend/app/schemas/profile.py @@ -0,0 +1,44 @@ +from pydantic import BaseModel + + +class RadarMetricResponse(BaseModel): + key: str + label: str + value: int + raw_label: str + + +class RecentChampionResponse(BaseModel): + champion_id: int + champion_name: str + games_played: int + + +class PlayerAchievementResponse(BaseModel): + id: str + label: str + description: str + source_field: str + count: int + + +class FeaturedGameSlideResponse(BaseModel): + game_name: str + cover_image_key: str + card_image_key: str | None = None + efficiency_score: int + time_spent_label: str + win_rate_label: str + kda_label: str + + +class PlayerProfileResponse(BaseModel): + display_name: str + riot_id_tag: str + avatar_initials: str + avatar_url: str | None = None + matches_sampled: int + radar_metrics: list[RadarMetricResponse] + recent_champions: list[RecentChampionResponse] + achievements: list[PlayerAchievementResponse] + featured_games: list[FeaturedGameSlideResponse] diff --git a/backend/app/schemas/profile_schemas.py b/backend/app/schemas/profile_schemas.py new file mode 100644 index 00000000..084659bf --- /dev/null +++ b/backend/app/schemas/profile_schemas.py @@ -0,0 +1,57 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class MessageResponse(BaseModel): + message: str = Field(..., description="Human-readable operation result") + + +class MatchSummary(BaseModel): + match_id: str + map: str + game_mode: str + duration: str + status: str + kda: str + champion: str + + +class RiotKeyUpdateResponse(BaseModel): + message: str + user: str + status: str + + +class PlayerSummary(BaseModel): + most_played_character: str + common_mistakes: list[str] + avg_kda: str + win_rate: str + + +class ProfileResponse(BaseModel): + uuid: str + username: str + total_matches: int + player_summary: PlayerSummary + + +class LiveAdvancedMetrics(BaseModel): + games_analyzed: int + avg_kda: str + avg_vision_score: float + avg_kill_participation_pct: float + avg_cs_per_minute: float + avg_damage_per_minute: float + avg_gold_per_minute: float + win_rate: str + + +class ProfileCreateRequest(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + riot_puuid: Optional[str] = None + + +class ProfileUpdateRequest(BaseModel): + username: Optional[str] = Field(default=None, min_length=3, max_length=50) + riot_puuid: Optional[str] = None diff --git a/backend/app/schemas/riot_schemas.py b/backend/app/schemas/riot_schemas.py new file mode 100644 index 00000000..889bc2fa --- /dev/null +++ b/backend/app/schemas/riot_schemas.py @@ -0,0 +1,306 @@ +from typing import List, Optional, Dict, Any +from pydantic import BaseModel + +# ============ Account API ============ + + +class RiotAccountResponse(BaseModel): + """Response from Riot Account API (by-riot-id)""" + + puuid: str + gameName: str + tagLine: str + + class Config: + json_schema_extra: Dict[str, Any] = { + "example": { + "puuid": "z1x2c3v4b5n6m7_8a9s0d1f2g3h4j5k6l7_8q9w0e1r2t3y4u5i6o7p8", + "gameName": "Sn1per1", + "tagLine": "NA2", + } + } + + +# ============ Summoner API (Legacy) ============ + + +class SummonerResponse(BaseModel): + """Response from Summoner API""" + + id: str + accountId: str + puuid: str + name: str + profileIconId: int + revisionDate: int + summonerLevel: int + + +# ============ Match API ============ + + +class ParticipantPerks(BaseModel): + """Perks/Runes for a participant""" + + statPerks: Dict[str, int] + styles: List[Dict[str, Any]] + + +class MatchParticipant(BaseModel): + """Complete participant data from match""" + + puuid: str + summonerId: str + summonerName: str + summonerLevel: int + championId: int + championName: str + teamPosition: str # TOP, JUNGLE, MIDDLE, BOTTOM, UTILITY + teamId: int + win: bool + + # KDA + kills: int + deaths: int + assists: int + + # Gold & CS + goldEarned: int + goldSpent: int + totalMinionsKilled: int + neutralMinionsKilled: int + + # Damage + totalDamageDealtToChampions: int + physicalDamageDealtToChampions: int + magicDamageDealtToChampions: int + trueDamageDealtToChampions: int + damageSelfMitigated: int + + # Vision + visionScore: int + wardsPlaced: int + wardsKilled: int + visionWardsBoughtInGame: int + + # Items + item0: int + item1: int + item2: int + item3: int + item4: int + item5: int + item6: int + + # Multikills + doubleKills: int + tripleKills: int + quadraKills: int + pentaKills: int + largestMultiKill: int + + # Objectives + baronKills: int + dragonKills: int + turretKills: int + inhibitorKills: int + objectivesStolen: int + + # Summoner Spells + summoner1Id: int + summoner1Casts: int + summoner2Id: int + summoner2Casts: int + + # Time + timePlayed: int + totalTimeSpentDead: int + longestTimeSpentLiving: int + + # Perks/Runes + perks: ParticipantPerks + + # Challenge stats + challenges: Optional[Dict[str, Any]] = None + + +class TeamObjective(BaseModel): + """Team objective stats""" + + first: bool + kills: int + + +class TeamObjectives(BaseModel): + """All team objectives""" + + baron: TeamObjective + dragon: TeamObjective + tower: TeamObjective + inhibitor: TeamObjective + riftHerald: TeamObjective + champion: Optional[TeamObjective] = None + horde: Optional[TeamObjective] = None + + +class TeamBan(BaseModel): + """Champion bans""" + + championId: int + pickTurn: int + + +class Team(BaseModel): + """Team data""" + + teamId: int + win: bool + bans: List[TeamBan] + objectives: TeamObjectives + + +class MatchInfo(BaseModel): + """Match info section""" + + gameId: int + gameCreation: int + gameDuration: int + gameEndTimestamp: int + gameStartTimestamp: int + gameMode: str + gameName: str + gameType: str + gameVersion: str + mapId: int + platformId: str + queueId: int + tournamentCode: Optional[str] = None + endOfGameResult: str + participants: List[MatchParticipant] + teams: List[Team] + + +class MatchMetadata(BaseModel): + """Match metadata""" + + dataVersion: str + matchId: str + participants: List[str] # List of PUUIDs + + +class RiotMatchResponse(BaseModel): + """Complete Riot match response""" + + metadata: MatchMetadata + info: MatchInfo + + +class RiotMatchListResponse(BaseModel): + """List of match IDs""" + + match_ids: List[str] + puuid: str + count: int + + +# simplified shcemas + + +class SimplifiedPlayerStats(BaseModel): + summoner_name: str + champion_name: str + kills: int + deaths: int + assists: int + kda: float + role: str + + double_kills: int + triple_kills: int + quadra_kills: int + penta_kills: int + largest_multikill: int + + primary_runes: Optional[List[int]] = None + secondary_runes: Optional[List[int]] = None + + class Config: + json_schema_extra: Dict[str, Any] = { + "example": { + "summoner_name": "CoolPlayer", + "champion_name": "Yasuo", + "kills": 12, + "deaths": 3, + "assists": 8, + "kda": 6.67, + "team_position": "MIDDLE", + "role": "SOLO", + "double_kills": 2, + "triple_kills": 1, + "quadra_kills": 0, + "penta_kills": 0, + "largest_multikill": 3, + "primary_runes": [8112, 8126, 8138, 8135], + "secondary_runes": [8232, 8234], + } + } + + +class SimplifiedTeammate(BaseModel): + summoner_name: str + champion_name: str + kills: int + deaths: int + assists: int + kda: float + role: str + + +class SimplifiedMatchResponse(BaseModel): + match_id: str + game_mode: str + map_id: int + duration_seconds: int + your_stats: SimplifiedPlayerStats + teammates: List[SimplifiedTeammate] + your_team_won: bool + + class Config: + json_schema_extra: Dict[str, Any] = { + "example": { + "match_id": "EUW1_1234567890", + "game_mode": "CLASSIC", + "map_id": 11, + "duration_seconds": 2345, + "your_stats": { + "summoner_name": "CoolPlayer", + "champion_name": "Yasuo", + "kills": 12, + "deaths": 3, + "assists": 8, + "kda": 6.67, + "team_position": "MIDDLE", + "role": "SOLO", + "double_kills": 2, + "triple_kills": 1, + "quadra_kills": 0, + "penta_kills": 0, + "largest_multikill": 3, + "primary_runes": [8112, 8126, 8138, 8135], + "secondary_runes": [8232, 8234], + }, + "teammates": [ + { + "summoner_name": "Teammate1", + "champion_name": "LeeSin", + "kills": 5, + "deaths": 4, + "assists": 10, + "kda": 3.75, + "team_position": "JUNGLE", + "role": "NONE", + } + ], + "your_team_won": True, + } + } diff --git a/backend/app/schemas/spatial_schemas.py b/backend/app/schemas/spatial_schemas.py new file mode 100644 index 00000000..8434b296 --- /dev/null +++ b/backend/app/schemas/spatial_schemas.py @@ -0,0 +1,20 @@ +from typing import List +from pydantic import BaseModel + + +class Coordinate(BaseModel): + x: float + y: float + + +class PlayerPath(BaseModel): + puuid: str + champion: str + path: List[Coordinate] # List of (x,y) points over time + + +class SpatialAnalysis(BaseModel): + match_id: str + player_paths: List[PlayerPath] + total_distance_covered: float + heatmap_intensity: List[float] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 00000000..8936c307 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field + + +class UserMeResponse(BaseModel): + id: str + email: str + display_name: str + avatar_url: str | None = None + riot_id_tag: str | None + has_linked_riot: bool + + +class UpdateUserMeRequest(BaseModel): + display_name: str = Field(min_length=1, max_length=64) + + +class AvatarUploadResponse(BaseModel): + avatar_url: str + + +class LinkGameAccountRequest(BaseModel): + riot_id: str | None = Field(default=None, min_length=3, max_length=64) + game_name: str | None = Field(default=None, min_length=1, max_length=32) + tag_line: str | None = Field(default=None, min_length=1, max_length=16) + + +class LinkGameAccountResponse(BaseModel): + puuid: str + riot_id_tag: str + message: str diff --git a/backend/app/services/analytics.py b/backend/app/services/analytics.py new file mode 100644 index 00000000..85853e82 --- /dev/null +++ b/backend/app/services/analytics.py @@ -0,0 +1,147 @@ +import asyncio +from typing import Any +from app.schemas.profile_schemas import LiveAdvancedMetrics +from app.services.riot_service import riot_service + + +class LiveAnalyticsService: + @staticmethod + def _empty_live_metrics() -> LiveAdvancedMetrics: + return LiveAdvancedMetrics( + games_analyzed=0, + avg_kda="0.0 / 0.0 / 0.0", + avg_vision_score=0.0, + avg_kill_participation_pct=0.0, + avg_cs_per_minute=0.0, + avg_damage_per_minute=0.0, + avg_gold_per_minute=0.0, + win_rate="0%", + ) + + @staticmethod + def _get_game_minutes(match: dict[str, Any]) -> float: + # Explicit type typing prevents "Unknown" tracking inside nested dicts + info: dict[str, Any] = match.get("info", {}) + duration_seconds: Any = info.get("gameDuration", 1) + + if isinstance(duration_seconds, (int, float)) and duration_seconds > 10000: + duration_seconds = duration_seconds / 1000 + + return float(duration_seconds) / 60.0 + + @staticmethod + def _find_player_and_team_kills( + participants: list[dict[str, Any]], puuid: str + ) -> tuple[dict[str, Any] | None, int]: + player = next((p for p in participants if p.get("puuid") == puuid), None) + + if not player: + return None, 0 + + user_team_id = player.get("teamId") + + team_kills = sum( + int(p.get("kills", 0)) + for p in participants + if p.get("teamId") == user_team_id + ) + + return player, team_kills + + @staticmethod + async def get_live_metrics_from_api( + server_region: str, puuid: str, count: int = 20 + ) -> LiveAdvancedMetrics: + """ + Queries live match details through RiotService and returns aggregated performance metrics. + """ + match_ids = await riot_service.get_match_ids( + server_region=server_region, + puuid=puuid, + count=count, + ) + + if not match_ids: + return LiveAnalyticsService._empty_live_metrics() + + tasks = [riot_service.get_match_detail(match_id) for match_id in match_ids] + matches_data: list[dict[str, Any]] = await asyncio.gather(*tasks) + + # Explicitly typing the accumulator dictionary fixes type inference errors + stats: dict[str, Any] = { + "kills": 0, + "deaths": 0, + "assists": 0, + "wins": 0, + "vision": 0, + "damage": 0, + "gold": 0, + "cs": 0, + "team_kills": 0, + "duration_minutes": 0.0, + "games_analyzed": 0, + } + + for match in matches_data: + if not match or "info" not in match: + continue + + info: dict[str, Any] = match["info"] + participants: list[dict[str, Any]] = info.get("participants", []) + + player, team_kills = LiveAnalyticsService._find_player_and_team_kills( + participants, + puuid, + ) + + if not player: + continue + + stats["games_analyzed"] += 1 + stats["duration_minutes"] += LiveAnalyticsService._get_game_minutes(match) + stats["team_kills"] += team_kills + + stats["kills"] += player.get("kills", 0) + stats["deaths"] += player.get("deaths", 0) + stats["assists"] += player.get("assists", 0) + stats["vision"] += player.get("visionScore", 0) + stats["damage"] += player.get("totalDamageDealtToChampions", 0) + stats["gold"] += player.get("goldEarned", 0) + stats["cs"] += player.get("totalMinionsKilled", 0) + player.get( + "neutralMinionsKilled", 0 + ) + + if player.get("win"): + stats["wins"] += 1 + + if stats["games_analyzed"] == 0: + return LiveAnalyticsService._empty_live_metrics() + + return LiveAnalyticsService._build_live_metrics_response(stats) + + @staticmethod + def _build_live_metrics_response(stats: dict[str, Any]) -> LiveAdvancedMetrics: + games: int = stats["games_analyzed"] + duration: float = stats["duration_minutes"] or 1.0 + + kill_participation = 0.0 + + if stats["team_kills"] > 0: + kill_participation = ( + (stats["kills"] + stats["assists"]) / stats["team_kills"] + ) * 100 + + return LiveAdvancedMetrics( + games_analyzed=games, + avg_kda=( + f"{stats['kills'] / games:.1f} / " + f"{stats['deaths'] / games:.1f} / " + f"{stats['assists'] / games:.1f}" + ), + avg_vision_score=round(stats["vision"] / games, 1), + avg_kill_participation_pct=round(kill_participation, 1), + avg_cs_per_minute=round(stats["cs"] / duration, 1), + avg_damage_per_minute=round(stats["damage"] / duration, 1), + avg_gold_per_minute=round(stats["gold"] / duration, 1), + win_rate=f"{round((stats['wins'] / games) * 100)}%", + ) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 00000000..ded45845 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,141 @@ +import boto3 +import hmac +import hashlib +import base64 +import asyncio +from fastapi import HTTPException +from app.config import get_settings +from botocore.exceptions import ClientError +from typing import TYPE_CHECKING, Any, NoReturn +from collections.abc import Mapping + +if TYPE_CHECKING: + from mypy_boto3_cognito_idp import CognitoIdentityProviderClient + +settings = get_settings() + +# Initialize the Cognito Client +client: "CognitoIdentityProviderClient" = boto3.client("cognito-idp", region_name=settings.aws_region) # type: ignore + + +def get_secret_hash(username: str): + """ + Cognito requires a keyed-hash to verify the client. + """ + msg = username + settings.cognito_client_id + dig = hmac.new( + str(settings.cognito_client_secret).encode("utf-8"), + msg=msg.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + return base64.b64encode(dig).decode() + + +def log_registration(username: str, email: str): + """Writes new user info to a local text file""" + with open("registrations.txt", "a") as f: + f.write(f"User: {username} | Email: {email} | Status: REGISTERED\n") + + +def _handle_cognito_error(e: ClientError) -> NoReturn: + """Helper to extract Cognito errors and raise a standardized HTTP exception.""" + error_code = e.response.get("Error", {}).get("Code", "UnknownError") + error_message = e.response.get("Error", {}).get("Message", str(e)) + + # Map common Cognito errors to appropriate HTTP status codes + status_code = 400 + if error_code in ["NotAuthorizedException", "UserNotFoundException"]: + status_code = 401 + elif error_code == "TooManyRequestsException": + status_code = 429 + + raise HTTPException(status_code=status_code, detail=error_message) + + +async def register_user(username: str, password: str, email: str) -> Mapping[str, Any]: + try: + response = await asyncio.to_thread( + client.sign_up, + ClientId=settings.cognito_client_id, + SecretHash=get_secret_hash(username), + Username=username, + Password=password, + UserAttributes=[{"Name": "email", "Value": email}], + ) + + # 2. AUTO-CONFIRM + # This makes the user active immediately so they can login. + if settings.debug: # Use debug flag from config.py + await asyncio.to_thread( + client.admin_confirm_sign_up, + UserPoolId=settings.cognito_user_pool_id, + Username=username, + ) + + await asyncio.to_thread(log_registration, username, email) + return response + + except ClientError as e: + _handle_cognito_error(e) + + +async def login_user(username: str, password: str) -> Mapping[str, Any]: + try: + response = await asyncio.to_thread( + client.initiate_auth, + ClientId=settings.cognito_client_id, + AuthFlow="USER_PASSWORD_AUTH", + AuthParameters={ + "USERNAME": username, + "PASSWORD": password, + "SECRET_HASH": get_secret_hash(username), + }, + ) + # This returns the AccessToken, IdToken, and RefreshToken + return response["AuthenticationResult"] + except ClientError as e: + _handle_cognito_error(e) + + +async def confirm_user(username: str, code: str): + """Confirm the user using the code sent to their email.""" + try: + await asyncio.to_thread( + client.confirm_sign_up, + ClientId=settings.cognito_client_id, + SecretHash=get_secret_hash(username), + Username=username, + ConfirmationCode=code, + ) + return {"status": "success"} + except ClientError as e: + _handle_cognito_error(e) + + +async def logout_user(access_token: str) -> dict[str, str]: + """ + Invalidates the user's tokens globally in Cognito. + """ + try: + await asyncio.to_thread(client.global_sign_out, AccessToken=access_token) + return {"status": "success", "message": "Logged out from all devices"} + except ClientError as e: + _handle_cognito_error(e) + + +async def revoke_refresh_token(refresh_token: str) -> dict[str, str]: + """ + Revokes a specific refresh token and its associated access tokens. + """ + try: + await asyncio.to_thread( + client.revoke_token, + Token=refresh_token, + ClientId=settings.cognito_client_id, + ClientSecret=settings.cognito_client_secret, + # SecretHash is NOT needed for revoke_token, + # but ClientSecret IS if your client has one + ) + return {"status": "success", "message": "Refresh token revoked."} + except ClientError as e: + _handle_cognito_error(e) diff --git a/backend/app/services/avatar_storage.py b/backend/app/services/avatar_storage.py new file mode 100644 index 00000000..0cc52ece --- /dev/null +++ b/backend/app/services/avatar_storage.py @@ -0,0 +1,62 @@ +from pathlib import Path + +from fastapi import HTTPException, UploadFile, status + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +UPLOADS_DIR = BACKEND_ROOT / "uploads" +AVATARS_DIR = UPLOADS_DIR / "avatars" + +MAX_AVATAR_BYTES = 2 * 1024 * 1024 + +ALLOWED_CONTENT_TYPES: dict[str, str] = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + + +def avatar_public_path(user_id: str, ext: str) -> str: + return f"/uploads/avatars/{user_id}{ext}" + + +def avatar_file_path(user_id: str, ext: str) -> Path: + return AVATARS_DIR / f"{user_id}{ext}" + + +def ensure_avatar_dir() -> None: + AVATARS_DIR.mkdir(parents=True, exist_ok=True) + + +def delete_avatar_files(user_id: str) -> None: + if not AVATARS_DIR.exists(): + return + for path in AVATARS_DIR.glob(f"{user_id}.*"): + path.unlink(missing_ok=True) + + +async def save_avatar(user_id: str, file: UploadFile) -> str: + content_type = (file.content_type or "").split(";")[0].strip().lower() + ext = ALLOWED_CONTENT_TYPES.get(content_type) + if not ext: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Avatar must be JPEG, PNG, or WebP", + ) + + data = await file.read() + if not data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Empty file", + ) + if len(data) > MAX_AVATAR_BYTES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Avatar must be 2 MB or smaller", + ) + + ensure_avatar_dir() + delete_avatar_files(user_id) + dest = avatar_file_path(user_id, ext) + dest.write_bytes(data) + return avatar_public_path(user_id, ext) diff --git a/backend/app/services/match_detail.py b/backend/app/services/match_detail.py new file mode 100644 index 00000000..06af767e --- /dev/null +++ b/backend/app/services/match_detail.py @@ -0,0 +1,124 @@ +import json + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col, select + +from app.database.models import Champions, Matches, Participants +from app.schemas.match import ( + ChampionBanResponse, + MatchDetailResponse, + ObjectivesSummaryResponse, + ParticipantDetailResponse, + TeamDetailResponse, +) +from app.utils.game_labels import map_label, queue_label + + +async def user_has_match_access( + session: AsyncSession, user_puuids: list[str], match_id: str +) -> bool: + if not user_puuids: + return False + result = await session.execute( + select(Participants.internal_id) + .where( + col(Participants.match_id) == match_id, + col(Participants.puuid).in_(user_puuids), + ) + .limit(1) + ) + return result.scalar_one_or_none() is not None + + +async def get_match_detail( + session: AsyncSession, match_id: str, viewer_puuid: str | None +) -> MatchDetailResponse | None: + result = await session.execute( + select(Matches).where(col(Matches.match_id) == match_id) + ) + match = result.scalar_one_or_none() + if not match or not match.detail_json: + return None + + payload = json.loads(match.detail_json) + teams_raw = payload.get("teams", []) + + ban_ids: set[int] = set() + for team in teams_raw: + ban_ids.update(team.get("bans", [])) + + name_by_id: dict[int, str] = {} + if ban_ids: + champ_result = await session.execute( + select(Champions.champion_id, Champions.name).where( + col(Champions.champion_id).in_(ban_ids) + ) + ) + name_by_id = { + int(champion_id): str(name) for champion_id, name in champ_result.all() + } + + teams: list[TeamDetailResponse] = [] + + for team in teams_raw: + participants: list[ParticipantDetailResponse] = [] + for p in team.get("participants", []): + puuid = p.get("puuid", "") + is_viewer = bool(viewer_puuid and puuid == viewer_puuid) or p.get( + "is_viewer", False + ) + participants.append( + ParticipantDetailResponse( + puuid=puuid, + riot_id=p.get("riot_id"), + champion_id=p["champion_id"], + champion_name=p["champion_name"], + position=p["position"], + win=p["win"], + kills=p["kills"], + deaths=p["deaths"], + assists=p["assists"], + cs=p["cs"], + gold_earned=p["gold_earned"], + damage_to_champions=p["damage_to_champions"], + vision_score=p["vision_score"], + items=p.get("items", []), + summoner_spells=p.get("summoner_spells", []), + is_viewer=is_viewer, + ) + ) + obj = team.get("objectives", {}) + bans = [ + ChampionBanResponse( + champion_id=ban_id, + champion_name=name_by_id.get(ban_id, f"Champion {ban_id}"), + ) + for ban_id in team.get("bans", []) + ] + teams.append( + TeamDetailResponse( + team_id=team["team_id"], + win=team["win"], + bans=bans, + objectives=ObjectivesSummaryResponse( + baron=obj.get("baron", 0), + dragon=obj.get("dragon", 0), + rift_herald=obj.get("rift_herald", 0), + tower=obj.get("tower", 0), + inhibitor=obj.get("inhibitor", 0), + ), + participants=participants, + ) + ) + + return MatchDetailResponse( + match_id=match.match_id, + game_creation=match.game_creation, + game_duration=match.game_duration, + game_version=match.game_version, + queue_id=match.queue_id, + queue_label=queue_label(match.queue_id), + map_id=match.map_id, + map_label=map_label(match.map_id), + teams=teams, + ) diff --git a/backend/app/services/match_history.py b/backend/app/services/match_history.py new file mode 100644 index 00000000..f94a328c --- /dev/null +++ b/backend/app/services/match_history.py @@ -0,0 +1,38 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col, select + +from app.database.models import Champions, Matches, Participants +from app.schemas.match import MatchHistorySummaryResponse +from app.utils.game_labels import map_label + + +async def list_match_history( + session: AsyncSession, puuid: str +) -> list[MatchHistorySummaryResponse]: + result = await session.execute( + select(Participants, Matches, Champions) + .join(Matches, col(Matches.match_id) == col(Participants.match_id)) + .join(Champions, col(Champions.champion_id) == col(Participants.champion_id)) + .where(col(Participants.puuid) == puuid) + .order_by(col(Matches.game_creation).desc(), col(Matches.match_id).desc()) + ) + rows = result.all() + summaries: list[MatchHistorySummaryResponse] = [] + for participant, match, champion in rows: + duration_minutes = max(1, round(match.game_duration / 60)) + summaries.append( + MatchHistorySummaryResponse( + match_id=match.match_id, + champion_name=champion.name, + outcome="Victory" if participant.win else "Defeat", + duration_minutes=duration_minutes, + map_label=map_label(match.map_id), + played_on=match.played_on.isoformat(), + kills=participant.kills, + deaths=participant.deaths, + assists=participant.assists, + cs=participant.cs, + position=participant.individual_position, + ) + ) + return summaries diff --git a/.devcontainer/Dockerfile b/backend/app/services/match_service.py similarity index 100% rename from .devcontainer/Dockerfile rename to backend/app/services/match_service.py diff --git a/backend/app/services/player_profile.py b/backend/app/services/player_profile.py new file mode 100644 index 00000000..e31475c7 --- /dev/null +++ b/backend/app/services/player_profile.py @@ -0,0 +1,243 @@ +from collections import Counter + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col, select + +from app.database.models import ( + AchievementDefinitions, + Champions, + GameAccounts, + Matches, + Participants, + UserAchievements, + UserFeaturedGames, + Users, +) +from app.schemas.profile import ( + FeaturedGameSlideResponse, + PlayerAchievementResponse, + PlayerProfileResponse, + RadarMetricResponse, + RecentChampionResponse, +) + + +def _avatar_initials(display_name: str) -> str: + parts = display_name.strip().split() + if not parts: + return "VP" + if len(parts) == 1: + return parts[0][:2].upper() + return f"{parts[0][0]}{parts[1][0]}".upper() + + +def _format_win_rate(wins: int, losses: int) -> str: + total = wins + losses + if total == 0: + return "—" + pct = round((wins / total) * 100) + return f"{pct}% ({wins}W / {losses}L)" + + +def _format_kda(avg: float) -> str: + return f"{avg:.1f} avg" + + +def _format_time_spent(total_seconds: int) -> str: + days, rem = divmod(total_seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, seconds = divmod(rem, 60) + return f"{days}:{hours:02d}:{minutes:02d}:{seconds:02d}" + + +def _normalize_radar(value: float, cap: float) -> int: + return min(100, max(0, round((value / cap) * 100))) + + +async def _load_matches_sampled(session: AsyncSession, puuid: str) -> int: + result = await session.execute( + select(GameAccounts.profile_matches_sampled).where(GameAccounts.puuid == puuid) + ) + value = result.scalar_one_or_none() + return int(value) if value is not None else 0 + + +async def _load_achievements( + session: AsyncSession, puuid: str +) -> list[PlayerAchievementResponse]: + result = await session.execute( + select(UserAchievements, AchievementDefinitions) + .join( + AchievementDefinitions, + col(AchievementDefinitions.id) == col(UserAchievements.achievement_id), + ) + .where(col(UserAchievements.puuid) == puuid) + .order_by(col(UserAchievements.achievement_id)) + ) + return [ + PlayerAchievementResponse( + id=definition.id, + label=definition.label, + description=definition.description, + source_field=definition.source_field, + count=ua.count, + ) + for ua, definition in result.all() + ] + + +async def _load_featured_games( + session: AsyncSession, puuid: str +) -> list[FeaturedGameSlideResponse]: + result = await session.execute( + select(UserFeaturedGames) + .where(col(UserFeaturedGames.puuid) == puuid) + .order_by(col(UserFeaturedGames.sort_order)) + ) + slides = result.scalars().all() + return [ + FeaturedGameSlideResponse( + game_name=slide.game_name, + cover_image_key=slide.cover_image_key, + card_image_key=slide.card_image_key, + efficiency_score=slide.efficiency_score, + time_spent_label=_format_time_spent(slide.time_spent_seconds), + win_rate_label=_format_win_rate(slide.wins, slide.losses), + kda_label=_format_kda(slide.average_kda), + ) + for slide in slides + ] + + +async def build_player_profile( + session: AsyncSession, + user: Users, + puuid: str | None, + riot_id_tag: str | None, +) -> PlayerProfileResponse: + display_name = user.display_name + tag = riot_id_tag or "Not linked" + initials = _avatar_initials(display_name) + avatar_url = user.avatar_url + + if not puuid: + return PlayerProfileResponse( + display_name=display_name, + riot_id_tag=tag, + avatar_initials=initials, + avatar_url=avatar_url, + matches_sampled=0, + radar_metrics=[], + recent_champions=[], + achievements=[], + featured_games=[], + ) + + matches_sampled = await _load_matches_sampled(session, puuid) + achievements = await _load_achievements(session, puuid) + featured_games = await _load_featured_games(session, puuid) + + result = await session.execute( + select(Participants, Matches, Champions) + .join(Matches, col(Matches.match_id) == col(Participants.match_id)) + .join(Champions, col(Champions.champion_id) == col(Participants.champion_id)) + .where(col(Participants.puuid) == puuid) + .order_by(col(Matches.game_creation).desc()) + ) + rows = result.all() + + if not rows: + return PlayerProfileResponse( + display_name=display_name, + riot_id_tag=tag, + avatar_initials=initials, + avatar_url=avatar_url, + matches_sampled=matches_sampled, + radar_metrics=[], + recent_champions=[], + achievements=achievements, + featured_games=featured_games, + ) + + total_duration = sum(m.game_duration for _, m, _ in rows) or 1 + + kda_values = [(p.kills + p.assists) / max(p.deaths, 1) for p, _, _ in rows] + avg_kda = sum(kda_values) / len(kda_values) + + total_cs = sum(p.cs for p, _, _ in rows) + total_gold = sum(p.gold_earned for p, _, _ in rows) + total_damage = sum(p.damage_to_champions for p, _, _ in rows) + total_vision = sum(p.vision_score for p, _, _ in rows) + kp_values = [p.kill_participation or 0.0 for p, _, _ in rows] + avg_kp = sum(kp_values) / len(kp_values) if kp_values else 0.0 + + cspm = total_cs / (total_duration / 60) + gpm = total_gold / (total_duration / 60) + dpm = total_damage / (total_duration / 60) + vision_per_min = total_vision / (total_duration / 60) + + champion_counts: Counter[int] = Counter() + champion_names: dict[int, str] = {} + for p, _, c in rows: + champion_counts[c.champion_id] += 1 + champion_names[c.champion_id] = c.name + + recent_champions = [ + RecentChampionResponse( + champion_id=cid, + champion_name=champion_names[cid], + games_played=count, + ) + for cid, count in champion_counts.most_common(5) + ] + + radar_metrics = [ + RadarMetricResponse( + key="kda", + label="KDA", + value=_normalize_radar(avg_kda, 5.0), + raw_label=_format_kda(avg_kda), + ), + RadarMetricResponse( + key="vision", + label="Vision", + value=_normalize_radar(vision_per_min, 2.0), + raw_label=f"{vision_per_min:.1f}/min", + ), + RadarMetricResponse( + key="gpm", + label="GPM", + value=_normalize_radar(gpm, 500.0), + raw_label=f"{round(gpm)} GPM", + ), + RadarMetricResponse( + key="dpm", + label="DPM", + value=_normalize_radar(dpm, 900.0), + raw_label=f"{round(dpm)} DPM", + ), + RadarMetricResponse( + key="cspm", + label="CS/min", + value=_normalize_radar(cspm, 10.0), + raw_label=f"{cspm:.1f} CS/min", + ), + RadarMetricResponse( + key="kp", + label="Kill Part.", + value=_normalize_radar(avg_kp * 100, 100.0), + raw_label=f"{round(avg_kp * 100)}% KP", + ), + ] + + return PlayerProfileResponse( + display_name=display_name, + riot_id_tag=tag, + avatar_initials=initials, + avatar_url=avatar_url, + matches_sampled=matches_sampled, + radar_metrics=radar_metrics, + recent_champions=recent_champions, + achievements=achievements, + featured_games=featured_games, + ) diff --git a/backend/app/services/profile_services.py b/backend/app/services/profile_services.py new file mode 100644 index 00000000..68b3d3e2 --- /dev/null +++ b/backend/app/services/profile_services.py @@ -0,0 +1,224 @@ +from datetime import datetime, timedelta, timezone +from fastapi import HTTPException, status +from sqlalchemy import Integer, cast, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col + +from app.database.models import Champions, Participants, UserProfile, GameAccounts +from app.schemas.profile_schemas import ( + PlayerSummary, + ProfileCreateRequest, + ProfileUpdateRequest, +) + + +def utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class ProfileService: + @staticmethod + async def get_or_create_profile(session: AsyncSession, user_id: str) -> UserProfile: + statement = select(UserProfile).where(col(UserProfile.user_id) == user_id) + result = await session.execute(statement) + profile = result.scalar_one_or_none() + + if profile: + return profile + + profile = UserProfile( + user_id=user_id, + username=f"Summoner_{user_id[:8]}", + ) + session.add(profile) + await session.commit() + await session.refresh(profile) + + return profile + + @staticmethod + async def build_player_summary( + session: AsyncSession, current_user: str + ) -> tuple[int, PlayerSummary]: + total_matches_stmt = select( + func.count(func.distinct(col(Participants.match_id))) + ).where(col(Participants.puuid) == current_user) + total_matches_result = await session.execute(total_matches_stmt) + total_matches = int(total_matches_result.scalar_one() or 0) + + most_played_stmt = ( + select(col(Champions.name), func.count()) + .join( + Participants, + col(Participants.champion_id) == col(Champions.champion_id), + ) + .where(col(Participants.puuid) == current_user) + .group_by(col(Champions.name)) + .order_by(func.count().desc()) + .limit(1) + ) + most_played_result = await session.execute(most_played_stmt) + most_played_row = most_played_result.one_or_none() + + stats_stmt = select( + func.coalesce(func.sum(col(Participants.kills)), 0).label("kills"), + func.coalesce(func.sum(col(Participants.deaths)), 0).label("deaths"), + func.coalesce(func.sum(col(Participants.assists)), 0).label("assists"), + func.coalesce(func.sum(cast(col(Participants.win), Integer)), 0).label( + "wins" + ), + func.count(col(Participants.match_id)).label("games_played"), + ).where(col(Participants.puuid) == current_user) + + stats_result = await session.execute(stats_stmt) + stats_row = stats_result.one() + + kills = int(stats_row.kills or 0) + deaths = int(stats_row.deaths or 0) + assists = int(stats_row.assists or 0) + wins = int(stats_row.wins or 0) + games_played = int(stats_row.games_played or 0) + + if games_played == 0: + return 0, PlayerSummary( + most_played_character="No matches yet", + common_mistakes=[], + avg_kda="0.0 / 0.0 / 0.0", + win_rate="0%", + ) + + avg_deaths = float(deaths) / games_played + common_mistakes: list[str] = [] + if avg_deaths >= 6: + common_mistakes.append("High average deaths") + if (float(assists) / games_played) < 5: + common_mistakes.append("Low average assists") + if not common_mistakes: + common_mistakes.append("No recurring mistakes detected") + + summary = PlayerSummary( + most_played_character=( + str(most_played_row[0]) if most_played_row else "Unknown" + ), + common_mistakes=common_mistakes, + avg_kda=( + f"{float(kills) / games_played:.1f} / " + f"{avg_deaths:.1f} / " + f"{float(assists) / games_played:.1f}" + ), + win_rate=f"{round((float(wins) / games_played) * 100)}%", + ) + + return total_matches, summary + + @staticmethod + async def schedule_account_deletion( + session: AsyncSession, user_id: str + ) -> datetime: + profile = await ProfileService.get_or_create_profile(session, user_id) + + deletion_date = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta( + days=30 + ) + profile.deletion_scheduled_at = deletion_date + profile.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + session.add(profile) + await session.commit() + await session.refresh(profile) + return deletion_date + + @staticmethod + async def undo_account_deletion(session: AsyncSession, user_id: str) -> bool: + statement = select(UserProfile).where(col(UserProfile.user_id) == user_id) + result = await session.execute(statement) + profile = result.scalar_one_or_none() + + if not profile or not profile.deletion_scheduled_at: + return False + + profile.deletion_scheduled_at = None + profile.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + session.add(profile) + await session.commit() + return True + + @staticmethod + async def create_profile( + session: AsyncSession, user_id: str, request: ProfileCreateRequest + ) -> UserProfile: + statement = select(UserProfile).where(col(UserProfile.user_id) == user_id) + result = await session.execute(statement) + existing_profile = result.scalar_one_or_none() + + if existing_profile: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Profile already exists." + ) + if request.riot_puuid is not None: + account_stmt = select(GameAccounts).where( + col(GameAccounts.puuid) == request.riot_puuid + ) + account_result = await session.execute(account_stmt) + game_account = account_result.scalar_one_or_none() + + if game_account is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Linked Riot account was not found.", + ) + + now = utc_now_naive() + + profile = UserProfile( + user_id=user_id, + username=request.username, + riot_puuid=request.riot_puuid, + created_at=now, + updated_at=now, + ) + + session.add(profile) + await session.commit() + await session.refresh(profile) + + return profile + + @staticmethod + async def update_profile( + session: AsyncSession, + user_id: str, + request: ProfileUpdateRequest, + ) -> UserProfile: + statement = select(UserProfile).where(col(UserProfile.user_id) == user_id) + result = await session.execute(statement) + profile = result.scalar_one_or_none() + + if profile is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail="Profile not found." + ) + + if request.riot_puuid is not None: + account_stmt = select(GameAccounts).where( + col(GameAccounts.puuid) == request.riot_puuid + ) + account_result = await session.execute(account_stmt) + game_account = account_result.scalar_one_or_none() + + if game_account is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Linked Riot account was not found.", + ) + profile.riot_puuid = request.riot_puuid + + if request.username is not None: + profile.username = request.username + + profile.updated_at = utc_now_naive() + + session.add(profile) + await session.commit() + await session.refresh(profile) + + return profile diff --git a/backend/app/services/riot_api.py b/backend/app/services/riot_api.py index e040124f..17e9bf5b 100644 --- a/backend/app/services/riot_api.py +++ b/backend/app/services/riot_api.py @@ -1,40 +1,64 @@ import os -import httpx from urllib.parse import quote + +import httpx from dotenv import load_dotenv load_dotenv() -API_KEY: str | None = os.getenv("RIOT_API_KEY") +# Account API routing clusters (try in order — EU accounts need `europe`, not `americas`) +ROUTING_CLUSTERS = ("europe", "americas", "asia", "sea") + + +class RiotApiNotConfiguredError(Exception): + """Raised when RIOT_API_KEY is missing from the environment.""" -# Riot ID lookups use regional routing (americas, europe, asia) -BASE_URL = "https://americas.api.riotgames.com" + +class RiotApiUnauthorizedError(Exception): + """Raised when the Riot API key is invalid or expired.""" + + +def _normalize_tag_line(tag_line: str) -> str: + return tag_line.strip().lstrip("#") async def get_puuid_by_riot_id(game_name: str, tag_line: str) -> str | None: - """Get PUUID by Riot ID (game name + tag).""" - if not API_KEY: - raise ValueError("RIOT_API_KEY environment variable is not set") + """Get PUUID by Riot ID (game name + tag), trying all regional routing clusters.""" + load_dotenv(override=True) + api_key = os.getenv("RIOT_API_KEY", "").strip() + if not api_key: + raise RiotApiNotConfiguredError( + "RIOT_API_KEY is not set. Add your Riot developer API key to backend/.env" + ) + + safe_game_name = quote(game_name.strip(), safe="") + safe_tag_line = quote(_normalize_tag_line(tag_line), safe="") + headers: dict[str, str] = {"X-Riot-Token": api_key} - # Safely encode user inputs - safe_game_name = quote(game_name, safe="") - safe_tag_line = quote(tag_line, safe="") + async with httpx.AsyncClient(timeout=15.0) as client: + for cluster in ROUTING_CLUSTERS: + url = ( + f"https://{cluster}.api.riotgames.com/riot/account/v1/" + f"accounts/by-riot-id/{safe_game_name}/{safe_tag_line}" + ) + response = await client.get(url, headers=headers) - url = f"{BASE_URL}/riot/account/v1/accounts/by-riot-id/{safe_game_name}/{safe_tag_line}" - headers: dict[str, str] = {"X-Riot-Token": API_KEY} + if response.status_code == 200: + puuid = response.json().get("puuid") + return str(puuid) if puuid else None - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) + if response.status_code == 401: + raise RiotApiUnauthorizedError( + "Riot API key is invalid or expired. Regenerate it at " + "https://developer.riotgames.com/ and update backend/.env" + ) - if response.status_code == 200: - puuid: str | None = response.json().get("puuid") - return puuid - elif response.status_code == 429: - print("Rate limit hit!") - elif response.status_code == 404: - print("Player not found.") - return None + if response.status_code == 429: + raise RiotApiUnauthorizedError( + "Riot API rate limit reached. Wait a minute and try again." + ) + # 404 on this cluster — try the next routing region + continue -# Neo : I created this for the purpose of testing the Riot API connection -# needs to be actually made for it using the MATCH V5 Api + return None diff --git a/backend/app/services/riot_service.py b/backend/app/services/riot_service.py new file mode 100644 index 00000000..f0c9209b --- /dev/null +++ b/backend/app/services/riot_service.py @@ -0,0 +1,318 @@ +import os +import httpx +from dotenv import load_dotenv +from app.config import get_settings +from fastapi import HTTPException +from typing import Any, Optional +from app.schemas.riot_schemas import ( + SimplifiedPlayerStats, + SimplifiedMatchResponse, + SimplifiedTeammate, +) + +load_dotenv() + +API_KEY = os.getenv("RIOT_API_KEY") +# Riot ID lookups use regional routing (americas, europe, asia) +BASE_URL = "https://americas.api.riotgames.com" +settings = get_settings() + + +def get_macro_region(server_region: str) -> str: + """Maps a local Riot server region to its Match-V5 macro-region.""" + region_map = { + # Americas + "na1": "americas", + "br1": "americas", + "la1": "americas", + "la2": "americas", + # Europe + "euw1": "europe", + "eun1": "europe", + "tr1": "europe", + "ru": "europe", + # Asia + "kr": "asia", + "jp1": "asia", + # South East Asia + "oc1": "sea", + "ph2": "sea", + "sg2": "sea", + "th2": "sea", + "tw2": "sea", + "vn2": "sea", + } + + # Default to americas if somehow not found + return region_map.get(server_region.lower(), "americas") + + +class RiotService: + def __init__(self): + self.headers = {"X-Riot-Token": settings.riot_api_key} + # Riot uses different base URLs for account data vs game data + self.account_url = ( + "https://europe.api.riotgames.com" # Region (americas, europe, etc) + ) + self.platform_url = ( + "https://euw1.api.riotgames.com" # Platform (na1, euw1, etc) + ) + + async def get_puuid(self, game_name: str, tag_line: str) -> str: + """ + In 2024+, you must use the Account-V1 API to get a PUUID + via GameName and TagLine (e.g., Hide on bush #KR1). + """ + url = f"{self.account_url}/riot/account/v1/accounts/by-riot-id/{game_name}/{tag_line}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers) + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, detail="Summoner not found" + ) + + data = response.json() + puuid = data.get("puuid") + + if not isinstance(puuid, str): + raise HTTPException(status_code=500, detail="Invalid Riot API Response") + + return puuid + + async def get_summoner_data(self, puuid: str): + """Gets level and profile icon using the PUUID.""" + url = f"{self.platform_url}/lol/summoner/v4/summoners/by-puuid/{puuid}" + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers) + if response.status_code == 200: + return response.json() + elif response.status_code == 404: + raise HTTPException( + status_code=404, detail="Summoner data not found for this PUUID." + ) + elif response.status_code == 429: + raise HTTPException( + status_code=429, + detail="Rate limit exceeded: Riot is throttling requests.", + ) + elif response.status_code in (401, 403): + raise HTTPException( + status_code=401, detail="Unauthorized: Check your Riot API Key." + ) + else: + raise HTTPException( + status_code=response.status_code, + detail=f"Riot API Error: {response.text}", + ) + + def set_api_key(self, new_key: str): + """ + Updates the internal headers with a new API key. + """ + self.headers["X-Riot-Token"] = new_key + return {"status": "success", "message": "Service headers updated"} + + async def get_match_ids( + self, server_region: str, puuid: str, count: int = 5 + ) -> list[str]: + # Fetches a list of match IDs for a given PUUID + + macro_region = get_macro_region(server_region) + # Dynamically inject the macro-region into the URL + base_url = f"https://{macro_region}.api.riotgames.com" + endpoint = f"/lol/match/v5/matches/by-puuid/{puuid}/ids?start=0&count={count}" + url = base_url + endpoint + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers) + + if response.status_code == 200: + # Explicitly cast to list[str] to prevent Pylance "Unknown" errors + return list(response.json()) + elif response.status_code == 401: + raise HTTPException( + status_code=401, detail="Unauthorized: Your Riot API Key has expired" + ) + elif response.status_code == 429: + raise HTTPException( + status_code=429, + detail="Rate limit exceeded: Riot is throttling requests", + ) + elif response.status_code == 404: + raise HTTPException( + status_code=404, detail="Data not found: PUUID has no match history" + ) + else: + raise HTTPException( + status_code=response.status_code, + detail="Failed to fetch match IDs from Riot", + ) + + async def get_match_detail(self, match_id: str) -> Any: + """ + Fetches the complete MatchDto dictionary from Riot's Match-V5 API. + """ + + server_region = match_id.split("_")[0].lower() + macro_region = get_macro_region(server_region) + url = ( + f"https://{macro_region}.api.riotgames.com/lol/match/v5/matches/{match_id}" + ) + + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=self.headers) + + if response.status_code == 200: + return response.json() + + elif response.status_code == 404: + raise HTTPException( + status_code=404, + detail=f"Match {match_id} not found on Riot servers", + ) + elif response.status_code == 429: + raise HTTPException( + status_code=429, + detail="Riot API rate limit exceeded. Try again later.", + ) + elif response.status_code == 403: + raise HTTPException( + status_code=403, detail="Riot API key is invalid or expired." + ) + else: + error_text: str = str(response.text) + raise HTTPException( + status_code=response.status_code, + detail=f"Riot API Error: {error_text}", + ) + + +riot_service = RiotService() + + +def simplify_participant(participant: Any) -> SimplifiedPlayerStats: + """Converts a raw Riot ParticipantDto into your clean format""" + + deaths_safe = max(participant["deaths"], 1) + calculated_kda = round( + (participant["kills"] + participant["assists"]) / deaths_safe, 2 + ) + + game_name = participant.get("riotIdGameName") + tag_line = participant.get("riotIdTagline") + fallback_name = participant.get("summonerName") + + if game_name and tag_line: + name = f"{game_name}#{tag_line}" + elif game_name: + name = game_name + elif fallback_name and fallback_name.strip(): + name = fallback_name + else: + name = participant.get("summonerName", "Unknown Summoner") + + assigned_role = ( + participant["teamPosition"] if participant["teamPosition"] else "UNKNOWN" + ) + + primary_runes = None + secondary_runes = None + + perks = participant.get("perks") + if perks and perks.get("style"): + for style in perks["styles"]: + if style.get("description") == "primaryStyle": + primary_runes = [ + selection["perk"] for selection in style.get("selections", []) + ] + elif style.get("description") == "subStyle": + secondary_runes = [ + selection["perk"] for selection in style.get("selections", []) + ] + + return SimplifiedPlayerStats( + summoner_name=name, + champion_name=participant["championName"], + kills=participant["kills"], + deaths=participant["deaths"], + assists=participant["assists"], + kda=calculated_kda, + role=assigned_role, + double_kills=participant["doubleKills"], + triple_kills=participant["tripleKills"], + quadra_kills=participant["quadraKills"], + penta_kills=participant["pentaKills"], + largest_multikill=participant["largestMultiKill"], + primary_runes=primary_runes, + secondary_runes=secondary_runes, + ) + + +def _format_teammate(p: Any) -> SimplifiedTeammate: + """Helper function to transform a raw participant into a SimplifiedTeammate.""" + # Handle Riot ID naming combinations + game_name = p.get("riotIdGameName") + tag_line = p.get("riotIdTagline") + fallback_name = p.get("summonerName") + + if game_name and tag_line: + t_name = f"{game_name}#{tag_line}" + elif game_name: + t_name = game_name + elif fallback_name and fallback_name.strip(): + t_name = fallback_name + else: + t_name = f"Teammate ({p.get('championName', 'Unknown')})" + + return SimplifiedTeammate( + summoner_name=t_name, + champion_name=p["championName"], + kills=p["kills"], + deaths=p["deaths"], + assists=p["assists"], + kda=round((p["kills"] + p["assists"]) / max(p["deaths"], 1), 2), + role=p["teamPosition"] if p["teamPosition"] else "UNKNOWN", + ) + + +def filter_match_for_players( + full_match: Any, target_puuid: str +) -> Optional[SimplifiedMatchResponse]: + # 1. Find the target participant without a standard for-loop + target_participant = next( + (p for p in full_match["info"]["participants"] if p["puuid"] == target_puuid), + None, + ) + + # Early return guard clause + if not target_participant: + return None + + target_team_id = target_participant["teamId"] + + # 2. Find if the team won without a standard for-loop + your_team_won = next( + ( + team["win"] + for team in full_match["info"]["teams"] + if team["teamId"] == target_team_id + ), + False, + ) + + # 3. Filter and build teammates using a list comprehension + helper function + teammates = [ + _format_teammate(p) + for p in full_match["info"]["participants"] + if p["teamId"] == target_team_id and p["puuid"] != target_puuid + ] + + return SimplifiedMatchResponse( + match_id=full_match["metadata"]["matchId"], + game_mode=full_match["info"]["gameMode"], + map_id=full_match["info"]["mapId"], + duration_seconds=full_match["info"]["gameDuration"], + your_team_won=your_team_won, + your_stats=simplify_participant(target_participant), + teammates=teammates, + ) diff --git a/backend/app/services/spatial_service.py b/backend/app/services/spatial_service.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/services/user_accounts.py b/backend/app/services/user_accounts.py new file mode 100644 index 00000000..a2098103 --- /dev/null +++ b/backend/app/services/user_accounts.py @@ -0,0 +1,140 @@ +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import col, select + +from app.database.models import GameAccounts, UserGameAccounts +from app.services.riot_api import ( + RiotApiNotConfiguredError, + RiotApiUnauthorizedError, + get_puuid_by_riot_id, +) +from app.utils.riot_id import parse_riot_id +from fastapi import HTTPException, status + + +def riot_id_tag(game_name: str, tag_line: str) -> str: + return f"{game_name}#{tag_line}" + + +async def get_primary_linked_account( + session: AsyncSession, user_id: str +) -> GameAccounts | None: + result = await session.execute( + select(GameAccounts) + .join( + UserGameAccounts, + col(UserGameAccounts.puuid) == col(GameAccounts.puuid), + ) + .where(col(UserGameAccounts.user_id) == user_id) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_primary_linked_puuid(session: AsyncSession, user_id: str) -> str | None: + result = await session.execute( + select(GameAccounts.puuid) + .join( + UserGameAccounts, + col(UserGameAccounts.puuid) == col(GameAccounts.puuid), + ) + .where(col(UserGameAccounts.user_id) == user_id) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_linked_puuids(session: AsyncSession, user_id: str) -> list[str]: + result = await session.execute( + select(GameAccounts.puuid) + .join( + UserGameAccounts, + col(UserGameAccounts.puuid) == col(GameAccounts.puuid), + ) + .where(col(UserGameAccounts.user_id) == user_id) + ) + return list(result.scalars().all()) + + +def _parse_link_request( + riot_id: str | None, + game_name: str | None, + tag_line: str | None, +) -> tuple[str, str]: + if riot_id: + try: + return parse_riot_id(riot_id) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + if game_name and tag_line: + return game_name.strip(), tag_line.strip() + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide riot_id or game_name and tag_line", + ) + + +async def link_riot_account_for_user( + session: AsyncSession, + user_id: str, + *, + riot_id: str | None = None, + game_name: str | None = None, + tag_line: str | None = None, +) -> tuple[str, str]: + """Resolve Riot ID, upsert GameAccounts, keep a single link per user.""" + game_name, tag_line = _parse_link_request(riot_id, game_name, tag_line) + + try: + puuid = await get_puuid_by_riot_id(game_name, tag_line) + except RiotApiNotConfiguredError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except RiotApiUnauthorizedError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + if not puuid: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=( + f"Could not find {game_name}#{tag_line} on Riot servers. " + "Check spelling (game name + tag, e.g. 6lordz#1072) and that your " + "developer API key is still valid." + ), + ) + + account_result = await session.execute( + select(GameAccounts).where(col(GameAccounts.puuid) == puuid) + ) + game_account = account_result.scalar_one_or_none() + if game_account: + game_account.game_name = game_name + game_account.tag_line = tag_line + session.add(game_account) + else: + game_account = GameAccounts( + puuid=puuid, + game="league_of_legends", + game_name=game_name, + tag_line=tag_line, + account_level=0, + ) + session.add(game_account) + await session.flush() + + await session.execute( + delete(UserGameAccounts).where(col(UserGameAccounts.user_id) == user_id) + ) + + session.add(UserGameAccounts(user_id=user_id, puuid=puuid)) + await session.commit() + + return puuid, riot_id_tag(game_name, tag_line) diff --git a/backend/app/tests/__pycache__/__init__.cpython-311.pyc b/backend/app/tests/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 4a93b0a5..00000000 Binary files a/backend/app/tests/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 72defbb1..4ffbcdcf 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -5,10 +5,18 @@ allowing tests to run while the database is still being set up. """ -import pytest -from fastapi.testclient import TestClient -from unittest.mock import MagicMock, AsyncMock -from app.main import app +pytest_plugins = ["app.tests.postgres_fixtures"] + +import os # noqa: E402 + +from app.tests.constants import TEST_JWT_SECRET, TEST_USER_PASSWORD # noqa: E402 + +os.environ.setdefault("JWT_SECRET", TEST_JWT_SECRET) + +import pytest # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from unittest.mock import MagicMock, AsyncMock # noqa: E402 +from app.main import app # noqa: E402 @pytest.fixture(scope="function") @@ -27,11 +35,12 @@ def test_user_data(): Provide sample user data for testing user-related endpoints. Returns: - dict: User data with username, email + dict: User data with display_name, email, password """ return { - "username": "testuser", + "display_name": "testuser", "email": "test@example.com", + "password": TEST_USER_PASSWORD, } @@ -41,13 +50,14 @@ def test_user_response(): Provide sample user response data (as returned from the API). Returns: - dict: User response with id, username, email, and timestamp + dict: User profile fields from GET /api/v1/users/me """ return { - "id": 1, - "username": "testuser", + "id": "00000000-0000-4000-8000-000000000099", "email": "test@example.com", - "created_at": "2024-01-15T10:30:00Z", + "display_name": "testuser", + "riot_id_tag": None, + "has_linked_riot": False, } diff --git a/backend/app/tests/constants.py b/backend/app/tests/constants.py new file mode 100644 index 00000000..609bc2db --- /dev/null +++ b/backend/app/tests/constants.py @@ -0,0 +1,6 @@ +"""Shared values for pytest only (not used in production).""" + +import os + +TEST_JWT_SECRET = os.getenv("JWT_SECRET", "pytest-jwt-secret") +TEST_USER_PASSWORD = os.getenv("TEST_USER_PASSWORD", "pytest-user-password") diff --git a/backend/app/tests/db_url.py b/backend/app/tests/db_url.py new file mode 100644 index 00000000..bf664577 --- /dev/null +++ b/backend/app/tests/db_url.py @@ -0,0 +1,7 @@ +"""Resolve DATABASE_URL for integration tests without embedding credentials.""" + +import os + + +def get_pytest_database_url() -> str | None: + return os.getenv("DATABASE_URL") diff --git a/backend/app/tests/postgres_fixtures.py b/backend/app/tests/postgres_fixtures.py new file mode 100644 index 00000000..b40a2f76 --- /dev/null +++ b/backend/app/tests/postgres_fixtures.py @@ -0,0 +1,72 @@ +"""Shared PostgreSQL integration-test fixtures (used by multiple test modules).""" + +import asyncio +import socket + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.pool import NullPool +from sqlmodel import SQLModel + +from app.database.session import get_session +from app.main import app +from app.tests.db_url import get_pytest_database_url +from app.tests.seed_fixtures import seed_test_user_with_matches + + +def postgres_available() -> bool: + try: + with socket.create_connection(("127.0.0.1", 5432), timeout=0.5): + return True + except OSError: + return False + + +DATABASE_URL = get_pytest_database_url() + +requires_postgres = pytest.mark.skipif( + not DATABASE_URL or not postgres_available(), + reason="DATABASE_URL must be set and PostgreSQL available on localhost:5432", +) + + +def _make_db_client(*, seed_matches: bool) -> tuple[TestClient, object]: + assert DATABASE_URL is not None + engine = create_async_engine(DATABASE_URL, poolclass=NullPool) + + async def _setup() -> None: + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + if seed_matches: + async with AsyncSession(engine) as session: + await seed_test_user_with_matches(session) + + asyncio.run(_setup()) + + async def override_get_session(): + async with AsyncSession(engine) as session: + yield session + + app.router.on_startup.clear() + app.dependency_overrides[get_session] = override_get_session + return TestClient(app), engine + + +@pytest.fixture +def db_client(): + client, engine = _make_db_client(seed_matches=False) + yield client + client.close() + app.dependency_overrides.clear() + asyncio.run(engine.dispose()) + + +@pytest.fixture +def seeded_db_client(): + client, engine = _make_db_client(seed_matches=True) + yield client + client.close() + app.dependency_overrides.clear() + asyncio.run(engine.dispose()) diff --git a/backend/app/tests/routes/__init__.py b/backend/app/tests/routes/__init__.py new file mode 100644 index 00000000..c21f967b --- /dev/null +++ b/backend/app/tests/routes/__init__.py @@ -0,0 +1 @@ +"""Routes tests package.""" diff --git a/backend/app/tests/routes/test_profile_routes.py b/backend/app/tests/routes/test_profile_routes.py new file mode 100644 index 00000000..92e8b74f --- /dev/null +++ b/backend/app/tests/routes/test_profile_routes.py @@ -0,0 +1,125 @@ +import pytest +from unittest.mock import patch, MagicMock +from fastapi import status +from datetime import datetime, timezone, timedelta +from app.main import app +from app.api.auth import get_current_user + + +class TestProfileRoutes: + """Test suite for /api/profile endpoints.""" + + @pytest.fixture(autouse=True) + def setup_auth_override(self): + """Override the get_current_user dependency for all tests in this class.""" + # This is the "FastAPI way" to mock dependencies + app.dependency_overrides[get_current_user] = lambda: "test-uuid-123" + yield + # Clean up after the tests are done + app.dependency_overrides.clear() + + @patch("app.services.profile_services.ProfileService.get_or_create_profile") + @patch("app.services.profile_services.ProfileService.build_player_summary") + async def test_get_profile_success(self, mock_summary, mock_get_profile, client): + """Test GET /api/profile returns 200 and profile data.""" + # Mock auth to return a fake user ID + mock_auth_id = "test-uuid-123" + + # Mock service responses + mock_profile = MagicMock() + mock_profile.user_id = mock_auth_id + mock_profile.username = "TestSummoner" + mock_get_profile.return_value = mock_profile + + # COMPLETE MOCK DATA FOR PLAYER SUMMARY + mock_player_summary = { + "most_played_character": "Thresh", + "common_mistakes": ["Poor positioning"], + "avg_kda": "3.5 / 2.0 / 12.0", + "win_rate": "65%", + "top_champions": ["Thresh"], + "recent_performance": "Excellent", + } + mock_summary.return_value = (10, mock_player_summary) + + response = client.get("/api/profile") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["uuid"] == mock_auth_id + assert data["player_summary"]["most_played_character"] == "Thresh" + + @patch("app.services.profile_services.ProfileService.create_profile") + @patch("app.services.profile_services.ProfileService.build_player_summary") + async def test_create_profile_success(self, mock_summary, mock_create, client): + """Test POST /api/profile success.""" + mock_profile = MagicMock() + mock_profile.user_id = "test-uuid-123" + mock_profile.username = "NewUser" + mock_create.return_value = mock_profile + + # COMPLETE MOCK DATA FOR PLAYER SUMMARY + mock_player_summary = { + "most_played_character": "N/A", + "common_mistakes": [], + "avg_kda": "0.0 / 0.0 / 0.0", + "win_rate": "0%", + "top_champions": [], + "recent_performance": "New Player", + } + mock_summary.return_value = (0, mock_player_summary) + + payload = {"username": "NewUser", "game_name": "RiotName", "tag_line": "1234"} + response = client.post("/api/profile", json=payload) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["username"] == "NewUser" + + @patch("app.services.profile_services.ProfileService.schedule_account_deletion") + async def test_delete_profile_success(self, mock_schedule, client): + """Test DELETE /api/profile success.""" + mock_schedule.return_value = datetime.now(timezone.utc) + timedelta(days=30) + + response = client.delete("/api/profile") + + assert response.status_code == status.HTTP_200_OK + assert "marked for deletion" in response.json()["message"] + + @patch("app.services.profile_services.ProfileService.undo_account_deletion") + async def test_undo_delete_success(self, mock_undo, client): + """Test POST /api/profile/undo-delete success.""" + mock_undo.return_value = True + + response = client.post("/api/profile/undo-delete") + + assert response.status_code == status.HTTP_200_OK + + @patch("app.services.profile_services.ProfileService.undo_account_deletion") + async def test_undo_delete_not_found(self, mock_undo, client): + """Test POST /api/profile/undo-delete failure when not marked.""" + mock_undo.return_value = False + + response = client.post("/api/profile/undo-delete") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class TestMiscRoutes: + """Test suite for Matches and Riot Key endpoints.""" + + @pytest.fixture(autouse=True) + def setup_auth_override(self): + app.dependency_overrides[get_current_user] = lambda: "test-uuid-123" + yield + app.dependency_overrides.clear() + + def test_get_matches_success(self, client): + """Test GET /api/matches returns mock match list.""" + response = client.get("/api/matches") + assert response.status_code == status.HTTP_200_OK + + def test_update_riot_key(self, client): + """Test PUT /api/profile/riot-key.""" + payload = {"riot_api_key": "RGAPI-test-key-123"} + response = client.put("/api/profile/riot-key", json=payload) + assert response.status_code == status.HTTP_200_OK diff --git a/backend/app/tests/routes/test_system.py b/backend/app/tests/routes/test_system.py new file mode 100644 index 00000000..cb053aaf --- /dev/null +++ b/backend/app/tests/routes/test_system.py @@ -0,0 +1,271 @@ +""" +Unit tests for system endpoints. + +Tests basic FastAPI application functionality and response schemas. +No database or external API calls required. +""" + +from fastapi import status + + +class TestRootEndpoint: + """Test suite for the root endpoint.""" + + def test_root_endpoint_returns_success(self, client): + """Test that GET / returns a successful response.""" + response = client.get("/") + assert response.status_code == status.HTTP_200_OK + + def test_root_endpoint_returns_correct_message(self, client): + """Test that GET / returns the correct message.""" + response = client.get("/") + data = response.json() + assert "message" in data + + def test_root_endpoint_response_format(self, client): + """Test that GET / returns properly formatted JSON.""" + response = client.get("/") + data = response.json() + assert isinstance(data, dict) + assert len(data) > 0 + + +class TestHealthEndpoint: + """Test suite for the health check endpoint.""" + + def test_health_endpoint_returns_success(self, client): + """Test that GET /health returns a successful response.""" + response = client.get("/health") + assert response.status_code == status.HTTP_200_OK + + def test_health_endpoint_returns_status(self, client): + """Test that GET /health returns status field.""" + response = client.get("/health") + data = response.json() + assert "status" in data + + def test_health_endpoint_response_structure(self, client): + """Test that GET /health returns properly structured JSON.""" + response = client.get("/health") + data = response.json() + assert isinstance(data, dict) + assert "status" in data + + def test_health_endpoint_content_type(self, client): + """Test that GET /health returns JSON content type.""" + response = client.get("/health") + assert response.headers["content-type"] == "application/json" + + +class TestTestEndpoint: + """Test suite for the test endpoint.""" + + def test_test_endpoint_returns_success(self, client): + """Test that POST /api/test returns a successful response.""" + payload = {"test_key": "test_value"} + response = client.post("/api/test", json=payload) + assert response.status_code == status.HTTP_200_OK + + def test_test_endpoint_echoes_received_data(self, client): + """Test that POST /api/test echoes back the received data.""" + payload = {"test_key": "test_value", "another_key": "another_value"} + response = client.post("/api/test", json=payload) + data = response.json() + assert "received" in data + assert data["received"] == payload + + def test_test_endpoint_returns_success_message(self, client): + """Test that POST /api/test includes a success message.""" + payload = {"test_key": "test_value"} + response = client.post("/api/test", json=payload) + data = response.json() + assert "message" in data + + def test_test_endpoint_with_empty_dict(self, client): + """Test that POST /api/test handles empty dictionary.""" + response = client.post("/api/test", json={}) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["received"] == {} + + def test_test_endpoint_with_complex_data(self, client): + """Test that POST /api/test handles complex nested data.""" + payload = {"nested": {"key": "value"}, "array": [1, 2, 3], "string": "test"} + response = client.post("/api/test", json=payload) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["received"] == payload + + +class TestErrorHandling: + """Test suite for error handling in main.py.""" + + def test_validation_error_response(self, client): + """Test that validation errors return proper error format.""" + # Send invalid JSON to /api/test (string instead of dict) + response = client.post("/api/test", json="not a dict") + + # The validation_exception_handler returns 400, not 422 + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = response.json() + # Error responses should have detail field + assert "detail" in data + + def test_not_found_error(self, client): + """Test that 404 returns proper error format.""" + # Request non-existent endpoint + response = client.get("/api/nonexistent-route") + + # Should return 404 Not Found + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_error_response_structure(self, client): + """Test that error responses have proper error structure.""" + # Request non-existent endpoint + response = client.get("/api/nonexistent") + + # Verify error response format + if response.status_code == 404: + data = response.json() + # Error responses should contain detail field or have content + assert "detail" in data or len(data) > 0 + + def test_http_exception_handler(self, client): + """Test HTTPException error handler.""" + # Request endpoint that doesn't exist (triggers HTTPException) + response = client.get("/api/does-not-exist") + + # Verify proper error status code + assert response.status_code in [404, 405] + # Verify error response has proper structure + assert response.headers.get("content-type") == "application/json" + + +class TestValidationErrorHandler: + """Test suite for RequestValidationError exception handler. + + Tests that validation errors are properly formatted and caught. + """ + + def test_validation_error_returns_400(self, client): + """Test that invalid request body returns 400 with error format. + + The validation_exception_handler should format errors properly. + """ + # Send POST to /api/test with invalid data type + # (should be dict, sending string) + response = client.post("/api/test", json="invalid string") + + # Validation error should return 400 + assert response.status_code == status.HTTP_400_BAD_REQUEST + data = response.json() + # Should have error response format from error_response() + assert "detail" in data or "status" in data + + def test_validation_error_format(self, client): + """Test that validation errors follow error_response format. + + Errors should include proper structure and content type. + """ + # Send request with wrong data type + response = client.post("/api/test", json="not a dict") + + # Check response format + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.headers.get("content-type") == "application/json" + + +# class TestRegisterSummonerRoute: +# """Test suite for POST /summoners/register endpoint. + +# Tests the summoner registration endpoint that integrates with Riot API. +# """ + +# @patch("app.services.riot_api.get_puuid_by_riot_id") +# @patch("app.database.session.async_session_maker") +# async def test_register_summoner_success( +# self, mock_session_maker, mock_get_puuid, client +# ): +# """Test successful summoner registration. + +# Mocks Riot API call and database session. +# """ +# # Mock Riot API to return a PUUID +# mock_get_puuid.return_value = "test-puuid-123" + +# # Mock database session +# mock_session = AsyncMock() +# mock_session_maker.return_value.__aenter__.return_value = mock_session + +# # Mock database query result (no existing account) +# mock_result = MagicMock() +# mock_result.scalar_one_or_none.return_value = None +# mock_session.execute = AsyncMock(return_value=mock_result) +# mock_session.commit = AsyncMock() + +# # Call endpoint +# response = client.post( +# "/summoners/register", params={"game_name": "TestPlayer", "tag_line": "NA1"} +# ) + +# # Verify success +# assert response.status_code == status.HTTP_200_OK +# data = response.json() +# assert "message" in data +# assert "Successfully registered" in data["message"] +# assert data["puuid"] == "test-puuid-123" + +# @patch("app.services.riot_api.get_puuid_by_riot_id") +# async def test_register_summoner_not_found(self, mock_get_puuid, client): +# """Test registration when player not found on Riot servers. + +# Tests error handling when Riot API returns no PUUID. +# """ +# # Mock Riot API to return None (player not found) +# mock_get_puuid.return_value = None + +# # Call endpoint +# response = client.post( +# "/summoners/register", +# params={"game_name": "NonExistent", "tag_line": "NA1"}, +# ) + +# # Verify error response +# assert response.status_code == status.HTTP_200_OK +# data = response.json() +# assert "error" in data +# assert "Could not find" in data["error"] + +# @patch("app.services.riot_api.get_puuid_by_riot_id") +# @patch("app.database.session.async_session_maker") +# async def test_register_summoner_already_exists( +# self, mock_session_maker, mock_get_puuid, client +# ): +# """Test registration when summoner already in database. + +# Tests handling of duplicate registrations. +# """ +# # Mock Riot API to return a PUUID +# mock_get_puuid.return_value = "existing-puuid-123" + +# # Mock database session +# mock_session = AsyncMock() +# mock_session_maker.return_value.__aenter__.return_value = mock_session + +# # Mock database query result (account already exists) +# mock_result = MagicMock() +# mock_existing_account = MagicMock() +# mock_result.scalar_one_or_none.return_value = mock_existing_account +# mock_session.execute = AsyncMock(return_value=mock_result) + +# # Call endpoint +# response = client.post( +# "/summoners/register", +# params={"game_name": "ExistingPlayer", "tag_line": "NA1"}, +# ) + +# # Verify response +# assert response.status_code == status.HTTP_200_OK +# data = response.json() +# assert "message" in data +# assert "already in database" in data["message"] diff --git a/backend/app/tests/seed_fixtures.py b/backend/app/tests/seed_fixtures.py new file mode 100644 index 00000000..3df5012a --- /dev/null +++ b/backend/app/tests/seed_fixtures.py @@ -0,0 +1,110 @@ +"""Minimal DB seed for match endpoint tests.""" + +from datetime import datetime, timezone + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.passwords import hash_password +from app.tests.constants import TEST_USER_PASSWORD +from app.database.models import ( + Champions, + GameAccounts, + Matches, + Participants, + UserGameAccounts, + Users, +) +from app.database.seed_data import ( + PROFILE_MATCHES_SAMPLED, + SEED_MATCHES, + SEED_VIEWER_PARTICIPANTS, + VIEWER_PUUID, +) +from app.database.seed_profile import seed_profile_for_puuid +from app.database.seed_data.matches import ( + GAME_VERSION, + MAP_ID, + QUEUE_ID, + build_detail_json, + game_creation_for, +) + + +async def seed_test_user_with_matches(session: AsyncSession) -> tuple[str, str]: + """Returns (user_id, access_token-ready email). Password: TEST_USER_PASSWORD.""" + user_id = "00000000-0000-4000-8000-000000000099" + email = "match_test@vantagepoint.dev" + + champion_names = { + 222: "Jinx", + 103: "Ahri", + 86: "Garen", + 64: "Lee Sin", + 51: "Caitlyn", + 122: "Darius", + 134: "Syndra", + 412: "Thresh", + } + for cid, name in champion_names.items(): + session.add(Champions(champion_id=cid, name=name, tags="Fighter")) + + session.add( + Users( + id=user_id, + email=email, + password_hash=hash_password(TEST_USER_PASSWORD), + display_name="MatchTest", + created_at=datetime.now(timezone.utc).replace(tzinfo=None), + ) + ) + session.add( + GameAccounts( + puuid=VIEWER_PUUID, + game="league_of_legends", + game_name="You", + tag_line="EUW", + account_level=100, + profile_matches_sampled=PROFILE_MATCHES_SAMPLED, + ) + ) + session.add(UserGameAccounts(user_id=user_id, puuid=VIEWER_PUUID)) + + for row in SEED_MATCHES: + session.add( + Matches( + match_id=row.match_id, + game_version=GAME_VERSION, + game_duration=row.game_duration, + queue_id=QUEUE_ID, + game_creation=game_creation_for(row.played_on, row.match_id), + map_id=MAP_ID, + played_on=row.played_on, + detail_json=build_detail_json(row.match_id), + ) + ) + + for vp in SEED_VIEWER_PARTICIPANTS: + session.add( + Participants( + match_id=vp.match_id, + puuid=VIEWER_PUUID, + champion_id=vp.champion_id, + win=vp.win, + kills=vp.kills, + deaths=vp.deaths, + assists=vp.assists, + individual_position=vp.individual_position, + team_id=vp.team_id, + cs=vp.cs, + gold_earned=vp.gold_earned, + damage_to_champions=vp.damage_to_champions, + vision_score=vp.vision_score, + kill_participation=vp.kill_participation, + riot_id_display="You#EUW", + ) + ) + + await seed_profile_for_puuid(session, VIEWER_PUUID, set_matches_sampled=False) + + await session.commit() + return user_id, email diff --git a/backend/app/tests/services/__init__.py b/backend/app/tests/services/__init__.py new file mode 100644 index 00000000..18f517ba --- /dev/null +++ b/backend/app/tests/services/__init__.py @@ -0,0 +1 @@ +"""Services tests package.""" diff --git a/backend/app/tests/services/test_auth.py b/backend/app/tests/services/test_auth.py new file mode 100644 index 00000000..ad976f6b --- /dev/null +++ b/backend/app/tests/services/test_auth.py @@ -0,0 +1,458 @@ +""" +Unit tests for authentication service. + +Tests user registration, login, confirmation, and token management. +Mocks only the external AWS Cognito dependency, allowing real service code to execute. +This increases code coverage by executing actual service logic. + +Also includes integration tests for authentication endpoints. +""" + +import pytest +from unittest.mock import patch, MagicMock +from fastapi import HTTPException +from botocore.exceptions import ClientError +from fastapi.testclient import TestClient +from app.services.auth_service import ( + register_user, + login_user, + confirm_user, + logout_user, + revoke_refresh_token, + get_secret_hash, + log_registration, + _handle_cognito_error, +) +from app.tests.constants import TEST_USER_PASSWORD + + +def _register_payload(email: str): + """Helper function to create registration payload.""" + return { + "email": email, + "display_name": "Test Player", + "password": TEST_USER_PASSWORD, + } + + +# ===================================================== +# Unit Tests - Service Layer +# ===================================================== + + +class TestGetSecretHash: + """Test suite for secret hash generation. + + These tests execute the real get_secret_hash() function. + No external dependencies, real coverage increase. + """ + + def test_get_secret_hash_returns_string(self): + """Test that get_secret_hash returns a base64 encoded string.""" + result = get_secret_hash("testuser") + assert isinstance(result, str) + assert len(result) > 0 + + def test_get_secret_hash_deterministic(self): + """Test that same username produces same hash.""" + hash1 = get_secret_hash("testuser") + hash2 = get_secret_hash("testuser") + assert hash1 == hash2 + + def test_get_secret_hash_different_for_different_users(self): + """Test that different usernames produce different hashes.""" + hash1 = get_secret_hash("user1") + hash2 = get_secret_hash("user2") + assert hash1 != hash2 + + +class TestLogRegistration: + """Test suite for registration logging. + + These tests execute the real log_registration() function. + Only the file I/O is mocked. + """ + + @patch("builtins.open", create=True) + def test_log_registration_writes_to_file(self, mock_open): + """Test that log_registration writes user info to file.""" + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + # Real function executes + log_registration("testuser", "test@example.com") + + # Verify file was opened and written to + mock_open.assert_called_once_with("registrations.txt", "a") + mock_file.write.assert_called_once() + written_content = mock_file.write.call_args[0][0] + assert "testuser" in written_content + assert "test@example.com" in written_content + assert "REGISTERED" in written_content + + @patch("builtins.open", create=True) + def test_log_registration_format(self, mock_open): + """Test that log_registration uses correct format.""" + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + # Real function executes + log_registration("john", "john@test.com") + + written_content = mock_file.write.call_args[0][0] + assert "User: john" in written_content + assert "Email: john@test.com" in written_content + + +class TestHandleCognitoError: + """Test suite for Cognito error handling. + + These tests execute the real _handle_cognito_error() function. + Tests error mapping logic. + """ + + def test_handle_cognito_error_not_auth_exception(self): + """Test that NotAuthorizedException returns 401.""" + error_response = { + "Error": {"Code": "NotAuthorizedException", "Message": "User not found"} + } + client_error = ClientError(error_response, "sign_up") + + # Real function executes + with pytest.raises(HTTPException) as exc_info: + _handle_cognito_error(client_error) + + assert exc_info.value.status_code == 401 + + def test_handle_cognito_error_too_many_requests(self): + """Test that TooManyRequestsException returns 429.""" + error_response = { + "Error": {"Code": "TooManyRequestsException", "Message": "Rate limited"} + } + client_error = ClientError(error_response, "sign_up") + + # Real function executes + with pytest.raises(HTTPException) as exc_info: + _handle_cognito_error(client_error) + + assert exc_info.value.status_code == 429 + + def test_handle_cognito_error_default_status_code(self): + """Test that unknown errors return 400.""" + error_response = { + "Error": {"Code": "SomeUnknownError", "Message": "Something went wrong"} + } + client_error = ClientError(error_response, "sign_up") + + # Real function executes + with pytest.raises(HTTPException) as exc_info: + _handle_cognito_error(client_error) + + assert exc_info.value.status_code == 400 + + +class TestRegisterUser: + """Test suite for user registration. + + Tests the real register_user() function logic. + Only mocks the Cognito client (external dependency). + """ + + @patch("app.services.auth_service.client") + @patch("app.services.auth_service.log_registration") + async def test_register_user_success(self, mock_log, mock_client): + """Test successful user registration. + + Real register_user() executes with mocked Cognito client. + """ + # Mock the Cognito client methods + mock_client.sign_up = MagicMock(return_value={"UserSub": "test-sub-123"}) + mock_client.admin_confirm_sign_up = MagicMock(return_value={}) + + # Real function executes + result = await register_user("testuser", "TestPass123!", "test@example.com") + + assert result is not None + assert "UserSub" in result + mock_client.sign_up.assert_called_once() + + @patch("app.services.auth_service.client") + async def test_register_user_cognito_error(self, mock_client): + """Test registration failure with Cognito error. + + Real register_user() executes and handles errors. + """ + error_response = { + "Error": { + "Code": "UsernameExistsException", + "Message": "User already exists", + } + } + + # Mock the client to raise error + mock_client.sign_up = MagicMock( + side_effect=ClientError(error_response, "sign_up") + ) + + # Real function executes and handles error + with pytest.raises(HTTPException) as exc_info: + await register_user("existinguser", "TestPass123!", "test@example.com") + + assert exc_info.value.status_code == 400 + + +class TestLoginUser: + """Test suite for user login. + + Tests the real login_user() function logic. + Only mocks asyncio.to_thread and Cognito client. + """ + + @patch("app.services.auth_service.asyncio.to_thread") + async def test_login_user_success(self, mock_to_thread): + """Test successful user login. + + Real login_user() executes. + """ + mock_response = { + "AuthenticationResult": { + "AccessToken": "access_token_123", + "IdToken": "id_token_123", + "RefreshToken": "refresh_token_123", + } + } + + def mock_to_thread_impl(func, *args, **kwargs): + return mock_response + + mock_to_thread.side_effect = mock_to_thread_impl + + # Real function executes + result = await login_user("testuser", "TestPass123!") + + assert "AccessToken" in result + assert result["AccessToken"] == "access_token_123" + + @patch("app.services.auth_service.asyncio.to_thread") + async def test_login_user_invalid_credentials(self, mock_to_thread): + """Test login failure with invalid credentials. + + Real login_user() executes and handles error. + """ + error_response = { + "Error": { + "Code": "NotAuthorizedException", + "Message": "Incorrect username or password", + } + } + + def mock_to_thread_impl(func, *args, **kwargs): + raise ClientError(error_response, "initiate_auth") + + mock_to_thread.side_effect = mock_to_thread_impl + + # Real function executes and handles error + with pytest.raises(HTTPException) as exc_info: + await login_user("testuser", "WrongPassword") + + assert exc_info.value.status_code == 401 + + +class TestConfirmUser: + """Test suite for user confirmation. + + Tests the real confirm_user() function logic. + Only mocks asyncio.to_thread. + """ + + @patch("app.services.auth_service.asyncio.to_thread") + async def test_confirm_user_success(self, mock_to_thread): + """Test successful user confirmation. + + Real confirm_user() executes. + """ + + def mock_to_thread_impl(func, *args, **kwargs): + return {} + + mock_to_thread.side_effect = mock_to_thread_impl + + # Real function executes + result = await confirm_user("testuser", "123456") + + assert result == {"status": "success"} + + @patch("app.services.auth_service.asyncio.to_thread") + async def test_confirm_user_invalid_code(self, mock_to_thread): + """Test confirmation failure with invalid code. + + Real confirm_user() executes and handles error. + """ + error_response = { + "Error": { + "Code": "InvalidParameterException", + "Message": "Invalid verification code", + } + } + + def mock_to_thread_impl(func, *args, **kwargs): + raise ClientError(error_response, "confirm_sign_up") + + mock_to_thread.side_effect = mock_to_thread_impl + + # Real function executes and handles error + with pytest.raises(HTTPException): + await confirm_user("testuser", "000000") + + +class TestLogoutUser: + """Test suite for user logout. + + Tests the real logout_user() function logic. + Only mocks asyncio.to_thread. + """ + + @patch("app.services.auth_service.asyncio.to_thread") + async def test_logout_user_success(self, mock_to_thread): + """Test successful user logout. + + Real logout_user() executes. + """ + + def mock_to_thread_impl(func, *args, **kwargs): + return {} + + mock_to_thread.side_effect = mock_to_thread_impl + + # Real function executes + result = await logout_user("valid_access_token") + + assert result["status"] == "success" + assert "Logged out" in result["message"] + + @patch("app.services.auth_service.asyncio.to_thread") + async def test_logout_user_invalid_token(self, mock_to_thread): + """Test logout failure with invalid token. + + Real logout_user() executes and handles error. + """ + error_response = { + "Error": { + "Code": "NotAuthorizedException", + "Message": "Invalid access token", + } + } + + def mock_to_thread_impl(func, *args, **kwargs): + raise ClientError(error_response, "global_sign_out") + + mock_to_thread.side_effect = mock_to_thread_impl + + # Real function executes and handles error + with pytest.raises(HTTPException) as exc_info: + await logout_user("invalid_token") + + assert exc_info.value.status_code == 401 + + +class TestRevokeRefreshToken: + """Test suite for refresh token revocation. + + Tests the real revoke_refresh_token() function logic. + Only mocks asyncio.to_thread. + """ + + @patch("app.services.auth_service.asyncio.to_thread") + async def test_revoke_refresh_token_success(self, mock_to_thread): + """Test successful refresh token revocation. + + Real revoke_refresh_token() executes. + """ + + def mock_to_thread_impl(func, *args, **kwargs): + return {} + + mock_to_thread.side_effect = mock_to_thread_impl + + # Real function executes + result = await revoke_refresh_token("valid_refresh_token") + + assert result["status"] == "success" + assert "revoked" in result["message"] + + @patch("app.services.auth_service.asyncio.to_thread") + async def test_revoke_refresh_token_invalid_token(self, mock_to_thread): + """Test revocation failure with invalid token. + + Real revoke_refresh_token() executes and handles error. + """ + error_response = { + "Error": { + "Code": "InvalidParameterException", + "Message": "Invalid refresh token", + } + } + + def mock_to_thread_impl(func, *args, **kwargs): + raise ClientError(error_response, "revoke_token") + + mock_to_thread.side_effect = mock_to_thread_impl + + # Real function executes and handles error + with pytest.raises(HTTPException): + await revoke_refresh_token("invalid_token") + + +# ===================================================== +# Integration Tests - API Endpoints +# ===================================================== + + +class TestAuthEndpoints: + """Integration tests for authentication endpoints. + + Tests the actual HTTP endpoints with mocked Cognito backend. + """ + + # @requires_postgres + # def test_register_login_and_me(self, db_client: TestClient): + # """Test registration and login flow.""" + # email = "auth_test@vantagepoint.dev" + # reg = db_client.post("/api/auth/register", json=_register_payload(email)) + # assert reg.status_code == 200 + # tokens = reg.json() + # assert "access_token" in tokens + # assert "refresh_token" in tokens + + # login = db_client.post( + # "/api/auth/login", + # json={"email": email, "password": TEST_USER_PASSWORD}, + # ) + # assert login.status_code == 200 + + # @requires_postgres + # def test_login_wrong_password(self, db_client: TestClient): + # """Test login with incorrect password.""" + # email = "wrong_pass@vantagepoint.dev" + # db_client.post("/api/auth/register", json=_register_payload(email)) + # login = db_client.post( + # "/api/auth/login", + # json={"email": email, "password": "wrong-password"}, + # ) + # assert login.status_code == 401 + + def test_me_without_token(self, client: TestClient): + """Test accessing protected endpoint without token.""" + # Note: This endpoint may not exist yet - adjust as needed + # Skip if the endpoint is not implemented + pass + + # @requires_postgres + # @patch("app.services.user_accounts.get_puuid_by_riot_id", new_callable=AsyncMock) + # def test_link_game_account(self, mock_puuid, db_client: TestClient): + # """Test linking a game account to user profile. + + # Note: This endpoint may not exist yet - adjust as needed + # """ + # # Skip if the endpoint is not implemented + # pass diff --git a/backend/app/tests/test_db.py b/backend/app/tests/test_db.py new file mode 100644 index 00000000..ce958fdd --- /dev/null +++ b/backend/app/tests/test_db.py @@ -0,0 +1,110 @@ +import os +from socket import socket +import pytest +from sqlalchemy.ext.asyncio import create_async_engine + +# Models now live in database/models.py — import from there, not from main. + +# TODO: add more models to this import as we add them to the database. We will need them for testing relationships and constraints. + + +def get_database_url(): + """Return DATABASE_URL that works both inside container and on host.""" + original = os.getenv("DATABASE_URL") + if original: + return original + + # Default: try to connect to 'db' (Docker), fallback to localhost + try: + socket.gethostbyname("db") + host = "db" + except socket.gaierror: + host = "localhost" + + user = os.getenv("POSTGRES_USER", "riot_user") + password = os.getenv("POSTGRES_PASSWORD", "") + db = os.getenv("POSTGRES_DB", "riot_db") + return f"postgresql+asyncpg://{user}:{password}@{host}:5432/{db}" + + +# Setup Connection +# DATABASE_URL = os.getenv("DATABASE_URL") +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+asyncpg://riot_user:riot_password@localhost:5432/riot_db", +) +# engine = create_async_engine(DATABASE_URL, echo=True) # echo=True shows the raw SQL + + +@pytest.fixture +def engine(): + """Create async engine fixture for database tests. + + echo=True shows the raw SQL for debugging. + Only creates engine when test runs, not at import time. + """ + _engine = create_async_engine(DATABASE_URL, echo=True) + yield _engine + + +# @pytest.mark.skip(reason="Database not available") +# @pytest.mark.integration +# @pytest.mark.asyncio +# async def test_database_logic(engine): +# """Test database connection and basic CRUD operations. + +# This is such basic testing and needs to be updated once we have more refined database logic and models, +# but it verifies that we can connect to the DB and perform basic operations. +# """ +# print("Starting Database Lab (lets hope this works)") + +# async with engine.begin() as conn: +# # This wipes the DB can test the creation logic +# print("Emptying Database...") +# await conn.run_sync(SQLModel.metadata.drop_all) +# print("Creating Tables...") +# await conn.run_sync(SQLModel.metadata.create_all) + +# async with AsyncSession(engine) as session: +# # Inserts a Mock Data +# print("Inserting Mock Data...") +# # Jhin's actual Riot champion_id is 202 — using a real value here +# # means this mock data would be valid if imported alongside real API data. +# # @NeoMachabaUP : There is no Jhin that we know of +# test_champ = Champions(champion_id=202, name="Jhin", tags="Marksman") +# test_game_account = GameAccounts( +# puuid="test_puuid_123", +# game="league_of_legends", +# game_name="TheFast", +# tag_line="4444", +# account_level=30, +# ) + +# session.add(test_champ) +# session.add(test_game_account) +# await session.commit() + +# # Tests Retrieval +# print("Testing Retrieval...") +# statement = select(GameAccounts).where(GameAccounts.game_name == "TheFast") +# result = await session.execute(statement) +# game_account = result.scalar_one() + +# print( +# f"Found Game Account: {game_account.game_name}#{game_account.tag_line} (PUUID: {game_account.puuid})" +# ) + +# # Verify the data was retrieved correctly +# assert game_account.game_name == "TheFast" +# assert game_account.puuid == "test_puuid_123" + +# print("--- Test complete ---") +# # TODO: add FK constraint tests (Participants referencing Matches/Summoners/Champions) +# # once the match history fetching logic is in place. + + +# if __name__ == "__main__": +# asyncio.run(test_database_logic()) + +# This is such basic testing and needs to be updated once we have more refinded database logic and models, +# but I just wanted to get something in here to test the connection and make sure we can write to the DB. diff --git a/backend/app/tests/test_db.py.skip b/backend/app/tests/test_db.py.skip deleted file mode 100644 index 5c7ac727..00000000 --- a/backend/app/tests/test_db.py.skip +++ /dev/null @@ -1,57 +0,0 @@ -import asyncio -import os -from sqlmodel import SQLModel, select #removed create_engine and Session because we are using the async versions now -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession - -# Models now live in database/models.py — import from there, not from main. -from app.database.models import Champions, Summoners, Matches, Participants - -#Setup Connection -DATABASE_URL = os.getenv("DATABASE_URL") -engine = create_async_engine(DATABASE_URL, echo=True) # echo=True shows the raw SQL - -async def test_database_logic(): - print("Starting Database Lab (lets hope this works)") - - async with engine.begin() as conn: - #This wipes the DB can test the creation logic - print("Emptying Database...") - await conn.run_sync(SQLModel.metadata.drop_all) - print("Creating Tables...") - await conn.run_sync(SQLModel.metadata.create_all) - - async with AsyncSession(engine) as session: - #Inserts a Mock Data - print("Inserting Mock Data...") - # Jhin's actual Riot champion_id is 202 — using a real value here - # means this mock data would be valid if imported alongside real API data. - # @NeoMachabaUP : There is no Jhin that we know of - test_champ = Champions(champion_id=202, name="Jhin", tags="Marksman") - test_summoner = Summoners( - puuid="test_puuid_123", - game_name="TheFast", - tag_line="4444", - summoner_level=100 - ) - - session.add(test_champ) - session.add(test_summoner) - await session.commit() - - #Tests Retrieval - print("Testing Retrieval...") - statement = select(Summoners).where(Summoners.game_name == "TheFast") - result = await session.execute(statement) - summoner = result.scalar_one() - - print(f"Found Summoner: {summoner.game_name}#{summoner.tag_line} (PUUID: {summoner.puuid})") - - print("--- Test complete ---") - # TODO: add FK constraint tests (Participants referencing Matches/Summoners/Champions) - # once the match history fetching logic is in place. - -if __name__ == "__main__": - asyncio.run(test_database_logic()) - -# This is such basic testing and needs to be updated once we have more refinded database logic and models, -# but I just wanted to get something in here to test the connection and make sure we can write to the DB. \ No newline at end of file diff --git a/backend/app/tests/test_main.py b/backend/app/tests/test_main.py index b0b21970..be461be1 100644 --- a/backend/app/tests/test_main.py +++ b/backend/app/tests/test_main.py @@ -1,97 +1,40 @@ """ -Unit tests for main application endpoints. +Main test suite aggregator. -Tests basic FastAPI application functionality and response schemas. -No database or external API calls required. +This file serves as the entry point for all tests. """ -from fastapi import status - - -class TestRootEndpoint: - """Test suite for the root endpoint.""" - - def test_root_endpoint_returns_success(self, client): - """Test that GET / returns a successful response.""" - response = client.get("/") - assert response.status_code == status.HTTP_200_OK - - def test_root_endpoint_returns_correct_message(self, client): - """Test that GET / returns the correct message.""" - response = client.get("/") - data = response.json() - assert "message" in data - - def test_root_endpoint_response_format(self, client): - """Test that GET / returns properly formatted JSON.""" - response = client.get("/") - data = response.json() - assert isinstance(data, dict) - assert len(data) > 0 - - -class TestHealthEndpoint: - """Test suite for the health check endpoint.""" - - def test_health_endpoint_returns_success(self, client): - """Test that GET /health returns a successful response.""" - response = client.get("/health") - assert response.status_code == status.HTTP_200_OK - - def test_health_endpoint_returns_status(self, client): - """Test that GET /health returns status field.""" - response = client.get("/health") - data = response.json() - assert "status" in data - - def test_health_endpoint_response_structure(self, client): - """Test that GET /health returns properly structured JSON.""" - response = client.get("/health") - data = response.json() - assert isinstance(data, dict) - assert "status" in data - - def test_health_endpoint_content_type(self, client): - """Test that GET /health returns JSON content type.""" - response = client.get("/health") - assert response.headers["content-type"] == "application/json" - - -class TestTestEndpoint: - """Test suite for the test endpoint.""" - - def test_test_endpoint_returns_success(self, client): - """Test that POST /api/test returns a successful response.""" - payload = {"test_key": "test_value"} - response = client.post("/api/test", json=payload) - assert response.status_code == status.HTTP_200_OK - - def test_test_endpoint_echoes_received_data(self, client): - """Test that POST /api/test echoes back the received data.""" - payload = {"test_key": "test_value", "another_key": "another_value"} - response = client.post("/api/test", json=payload) - data = response.json() - assert "received" in data - assert data["received"] == payload - - def test_test_endpoint_returns_success_message(self, client): - """Test that POST /api/test includes a success message.""" - payload = {"test_key": "test_value"} - response = client.post("/api/test", json=payload) - data = response.json() - assert "message" in data - - def test_test_endpoint_with_empty_dict(self, client): - """Test that POST /api/test handles empty dictionary.""" - response = client.post("/api/test", json={}) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["received"] == {} - - def test_test_endpoint_with_complex_data(self, client): - """Test that POST /api/test handles complex nested data.""" - payload = {"nested": {"key": "value"}, "array": [1, 2, 3], "string": "test"} - response = client.post("/api/test", json=payload) - assert response.status_code == status.HTTP_200_OK - data = response.json() - assert data["received"] == payload +# Import system endpoint tests +from app.tests.routes.test_system import ( + TestRootEndpoint, + TestHealthEndpoint, + TestTestEndpoint, +) + +# Import service tests +from app.tests.services.test_auth import ( + TestGetSecretHash, + TestLogRegistration, + TestHandleCognitoError, + TestRegisterUser, + TestLoginUser, + TestConfirmUser, + TestLogoutUser, + TestRevokeRefreshToken, +) + +__all__ = [ + # System endpoints + "TestRootEndpoint", + "TestHealthEndpoint", + "TestTestEndpoint", + # Auth service + "TestGetSecretHash", + "TestLogRegistration", + "TestHandleCognitoError", + "TestRegisterUser", + "TestLoginUser", + "TestConfirmUser", + "TestLogoutUser", + "TestRevokeRefreshToken", +] diff --git a/backend/app/tests/test_matches.py b/backend/app/tests/test_matches.py new file mode 100644 index 00000000..a6464355 --- /dev/null +++ b/backend/app/tests/test_matches.py @@ -0,0 +1,102 @@ +from typing import cast + +from fastapi.testclient import TestClient + +from app.tests.constants import TEST_USER_PASSWORD + + +def _login(client: TestClient, email: str) -> str: + response = client.post( + "/api/v1/auth/login", + json={"email": email, "password": TEST_USER_PASSWORD}, + ) + assert response.status_code == 200 + return cast(str, response.json()["access_token"]) + + +# @requires_postgres +# def test_match_list_detail_and_profile(seeded_db_client: TestClient): +# client = seeded_db_client +# email = "match_test@vantagepoint.dev" +# token = _login(client, email) +# headers = {"Authorization": f"Bearer {token}"} + +# history = client.get("/api/v1/matches", headers=headers) +# assert history.status_code == 200 +# items = history.json() +# assert len(items) == 8 +# match_8 = next(i for i in items if i["match_id"] == "EUW1_700000008") +# assert match_8["champion_name"] == "Thresh" + +# def viewer_from_detail(body: dict[str, Any]) -> dict[str, Any]: +# for team in body["teams"]: +# for participant in team["participants"]: +# if participant["is_viewer"]: +# return cast(dict[str, Any], participant) +# raise AssertionError("viewer not found in match detail") + +# match_1_list = next(i for i in items if i["match_id"] == "EUW1_700000001") +# detail_1 = client.get("/api/v1/matches/EUW1_700000001", headers=headers) +# assert detail_1.status_code == 200 +# detail_1_body = detail_1.json() +# red_team = next(t for t in detail_1_body["teams"] if t["team_id"] == 200) +# assert len(red_team["bans"]) == 5 +# lee_sin_ban = next(b for b in red_team["bans"] if b["champion_id"] == 64) +# assert lee_sin_ban["champion_name"] == "Lee Sin" +# viewer_1 = viewer_from_detail(detail_1_body) +# assert viewer_1["champion_id"] == 222 +# assert viewer_1["kills"] == match_1_list["kills"] +# assert viewer_1["deaths"] == match_1_list["deaths"] +# assert viewer_1["assists"] == match_1_list["assists"] +# assert viewer_1["win"] is False + +# match_5_list = next(i for i in items if i["match_id"] == "EUW1_700000005") +# detail_5 = client.get("/api/v1/matches/EUW1_700000005", headers=headers) +# assert detail_5.status_code == 200 +# viewer_5 = viewer_from_detail(detail_5.json()) +# assert viewer_5["champion_id"] == 51 +# assert viewer_5["kills"] == 11 +# assert viewer_5["deaths"] == 2 +# assert viewer_5["assists"] == 5 +# assert viewer_5["win"] is True +# assert match_5_list["outcome"] == "Victory" + +# detail_6 = client.get("/api/v1/matches/EUW1_700000006", headers=headers).json() +# viewer_6 = viewer_from_detail(detail_6) +# assert viewer_5["gold_earned"] != viewer_6["gold_earned"] + +# profile = client.get("/api/v1/users/me/profile", headers=headers) +# assert profile.status_code == 200 +# prof = profile.json() +# assert prof["matches_sampled"] == 20 +# assert len(prof["achievements"]) == 7 +# assert prof["achievements"][0]["id"] == "damage" +# assert len(prof["featured_games"]) == 2 +# assert prof["featured_games"][0]["efficiency_score"] == 115 +# assert prof["featured_games"][0]["win_rate_label"] == "65% (13W / 7L)" +# assert len(prof["radar_metrics"]) == 6 +# assert len(prof["recent_champions"]) >= 1 + +# missing = client.get("/api/v1/matches/EUW1_nonexistent", headers=headers) +# assert missing.status_code == 404 + + +# @requires_postgres +# def test_matches_empty_without_linked_account(db_client: TestClient): +# client = db_client +# email = "nolink@vantagepoint.dev" +# reg = client.post( +# "/api/v1/auth/register", +# json={ +# "email": email, +# "display_name": "No Link", +# "password": TEST_USER_PASSWORD, +# }, +# ) +# assert reg.status_code == 200 +# token = reg.json()["access_token"] +# headers = {"Authorization": f"Bearer {token}"} + +# history = client.get("/api/v1/matches", headers=headers) +# assert history.status_code == 200 +# assert history.json() == [] diff --git a/backend/app/tests/test_user_profile.py b/backend/app/tests/test_user_profile.py new file mode 100644 index 00000000..01308b8a --- /dev/null +++ b/backend/app/tests/test_user_profile.py @@ -0,0 +1,103 @@ +from typing import cast + +from fastapi.testclient import TestClient + +from app.tests.constants import TEST_USER_PASSWORD + + +def _register_and_token(client: TestClient, email: str) -> str: + reg = client.post( + "/api/v1/auth/register", + json={ + "email": email, + "display_name": "Test Player", + "password": TEST_USER_PASSWORD, + }, + ) + assert reg.status_code == 200 + return cast(str, reg.json()["access_token"]) + + +# @requires_postgres +# def test_patch_me_updates_display_name(db_client: TestClient): +# client = db_client +# token = _register_and_token(client, "patch_me@vantagepoint.dev") +# headers = {"Authorization": f"Bearer {token}"} + +# patch = client.patch( +# "/api/v1/users/me", +# json={"display_name": "Renamed Player"}, +# headers=headers, +# ) +# assert patch.status_code == 200 +# assert patch.json()["display_name"] == "Renamed Player" + +# me = client.get("/api/v1/users/me", headers=headers) +# assert me.json()["display_name"] == "Renamed Player" + + +# @requires_postgres +# def test_upload_and_delete_avatar(db_client: TestClient): +# client = db_client +# token = _register_and_token(client, "avatar_me@vantagepoint.dev") +# headers = {"Authorization": f"Bearer {token}"} + +# png_bytes = ( +# b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" +# b"\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" +# ) +# upload = client.post( +# "/api/v1/users/me/avatar", +# headers=headers, +# files={"file": ("avatar.png", io.BytesIO(png_bytes), "image/png")}, +# ) +# assert upload.status_code == 200 +# avatar_url = upload.json()["avatar_url"] +# assert avatar_url.startswith("/uploads/avatars/") + +# static = client.get(avatar_url) +# assert static.status_code == 200 + +# me = client.get("/api/v1/users/me", headers=headers) +# assert me.json()["avatar_url"] == avatar_url + +# delete = client.delete("/api/v1/users/me/avatar", headers=headers) +# assert delete.status_code == 204 + +# me_after = client.get("/api/v1/users/me", headers=headers) +# assert me_after.json()["avatar_url"] is None + + +# @requires_postgres +# @patch( +# "app.services.user_accounts.get_puuid_by_riot_id", +# new_callable=AsyncMock, +# ) +# def test_relink_refreshes_game_account_names(mock_puuid, db_client: TestClient): +# client = db_client +# mock_puuid.return_value = "stable-puuid-relink" +# token = _register_and_token(client, "relink_me@vantagepoint.dev") +# headers = {"Authorization": f"Bearer {token}"} + +# link = client.post( +# "/api/v1/users/me/game-accounts", +# json={"riot_id": "OldName#EUW"}, +# headers=headers, +# ) +# assert link.status_code == 200 +# assert link.json()["riot_id_tag"] == "OldName#EUW" + +# relink = client.put( +# "/api/v1/users/me/game-accounts", +# json={"riot_id": "NewName#EUW"}, +# headers=headers, +# ) +# assert relink.status_code == 200 +# assert relink.json()["riot_id_tag"] == "NewName#EUW" + +# me = client.get("/api/v1/users/me", headers=headers) +# assert me.json()["riot_id_tag"] == "NewName#EUW" + +# profile = client.get("/api/v1/users/me/profile", headers=headers) +# assert profile.status_code == 200 +# assert profile.json()["riot_id_tag"] == "NewName#EUW" diff --git a/backend/app/utils/game_labels.py b/backend/app/utils/game_labels.py new file mode 100644 index 00000000..fb305b02 --- /dev/null +++ b/backend/app/utils/game_labels.py @@ -0,0 +1,19 @@ +QUEUE_LABELS: dict[int, str] = { + 420: "Ranked Solo/Duo", + 450: "ARAM", + 400: "Normal Draft", + 430: "Normal Blind", +} + +MAP_LABELS: dict[int, str] = { + 11: "Summoner's Rift", + 12: "Howling Abyss", +} + + +def queue_label(queue_id: int) -> str: + return QUEUE_LABELS.get(queue_id, f"Queue {queue_id}") + + +def map_label(map_id: int) -> str: + return MAP_LABELS.get(map_id, f"Map {map_id}") diff --git a/backend/app/utils/helpers.py b/backend/app/utils/helpers.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/utils/rate_limiting.py b/backend/app/utils/rate_limiting.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/utils/riot_id.py b/backend/app/utils/riot_id.py new file mode 100644 index 00000000..0b205694 --- /dev/null +++ b/backend/app/utils/riot_id.py @@ -0,0 +1,11 @@ +def parse_riot_id(riot_id: str) -> tuple[str, str]: + """Split a Riot ID into game name and tag line (split on last #).""" + trimmed = riot_id.strip() + if "#" not in trimmed: + raise ValueError("Riot ID must include a tag, e.g. Player#EUW") + idx = trimmed.rindex("#") + game_name = trimmed[:idx].strip() + tag_line = trimmed[idx + 1 :].strip().lstrip("#") + if not game_name or not tag_line: + raise ValueError("Riot ID must include both name and tag") + return game_name, tag_line diff --git a/backend/coverage-badge.svg b/backend/coverage-badge.svg new file mode 100644 index 00000000..3cfc108f --- /dev/null +++ b/backend/coverage-badge.svg @@ -0,0 +1 @@ +coverage: 52.7%coverage52.7% \ No newline at end of file diff --git a/backend/mypy.ini b/backend/mypy.ini index ea4accbc..c65c84eb 100644 --- a/backend/mypy.ini +++ b/backend/mypy.ini @@ -4,9 +4,6 @@ warn_return_any = True warn_unused_configs = True ignore_missing_imports = False -[mypy-sklearn.*] -ignore_missing_imports = True - [mypy-sklearn.datasets] ignore_missing_imports = True diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 00000000..dfb18f11 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 06b62633..91deff3d 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -2,7 +2,7 @@ -r requirements.txt # Testing -pytest==7.4.3 +pytest>=9.0.3 pytest-cov==4.1.0 pytest-asyncio==0.21.1 @@ -10,8 +10,16 @@ pytest-asyncio==0.21.1 black==26.3.1 ruff==0.1.11 mypy==1.8.0 + +# Type stubs for third-party libraries types-python-jose==3.5.0.20260408 +types-boto3-cognito-idp==1.43.0 boto3-stubs==1.43.7 # Utilities python-dotenv==1.2.2 + +joblib==1.5.2 +python-jose[cryptography]==3.5.0 +mypy-boto3-cognito-idp==1.43.0 +psycopg2-binary diff --git a/backend/requirements.txt b/backend/requirements.txt index 340bf4af..b7d51cfa 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,8 +4,16 @@ sqlalchemy==2.0.25 asyncpg==0.29.0 sqlmodel #pydantic==2.6.0 -#pydantic-settings==2.2.0 +pydantic-settings==2.14.1 +pydantic[email]==2.13.4 pandas==2.2.0 scikit-learn==1.5.0 httpx==0.26.0 python-dotenv==1.2.2 +python-jose[cryptography]==3.5.0 +bcrypt==4.2.1 +python-multipart==0.0.27 +boto3==1.43.12 +email-validator==2.1.0 +joblib==1.5.2 +idna>=3.15 diff --git a/backend/scripts/generate-coverage-badge.sh b/backend/scripts/generate-coverage-badge.sh new file mode 100755 index 00000000..34b44424 --- /dev/null +++ b/backend/scripts/generate-coverage-badge.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -e + +echo "Generating backend coverage badge..." + +# Check if coverage.xml exists +if [ ! -f "coverage.xml" ]; then + echo "coverage.xml not found. Running tests first..." + pytest --cov=app --cov-report=xml --cov-report=html --cov-report=term +fi + +# Extract coverage percentage from XML +COVERAGE=$(python -c " +import xml.etree.ElementTree as ET +root = ET.parse('coverage.xml').getroot() +coverage = float(root.attrib['line-rate']) * 100 +print(f'{coverage:.1f}') +") + +echo "Coverage: ${COVERAGE}%" + +# Determine color based on coverage (using bash arithmetic) +COVERAGE_INT=${COVERAGE%.*} +if [ "$COVERAGE_INT" -ge 80 ]; then + COLOR="green" +elif [ "$COVERAGE_INT" -ge 70 ]; then + COLOR="yellow" +else + COLOR="red" +fi + +# Generate badge using shields.io +curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" -o coverage-badge.svg + +if [ -f "coverage-badge.svg" ]; then + echo "Badge saved: coverage-badge.svg" + echo "Coverage: ${COVERAGE}% (${COLOR})" + ls -lh coverage-badge.svg +else + echo "Failed to generate badge" + exit 1 +fi diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..5934e2e7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:8000 diff --git a/frontend/coverage-badge.svg b/frontend/coverage-badge.svg new file mode 100644 index 00000000..b6e6cb78 --- /dev/null +++ b/frontend/coverage-badge.svg @@ -0,0 +1 @@ +coverage: 17.3%coverage17.3% \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ea36dd3d..d40a80bd 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,21 +1,43 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import { defineConfig, globalIgnores } from "eslint/config"; +import tseslint from "typescript-eslint"; export default defineConfig([ - globalIgnores(['dist']), + globalIgnores(["dist", "coverage/**"]), + js.configs.recommended, + ...tseslint.configs.recommended, { - files: ['**/*.{js,jsx}'], - extends: [ - js.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, languageOptions: { globals: globals.browser, - parserOptions: { ecmaFeatures: { jsx: true } }, + parserOptions: { + ecmaFeatures: { jsx: true }, + }, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-hooks/set-state-in-effect": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, + { + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: globals.node, }, }, -]) +]); diff --git a/frontend/index.html b/frontend/index.html index ea698977..e50e19f9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,10 +4,22 @@ - vantage-point + + + Vantage Point +
- + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4ee18729..bab92d7b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,52 +8,89 @@ "name": "vantage-point", "version": "0.0.0", "dependencies": { - "d3": "^7.9.0", - "format": "^0.2.2", + "@emotion/react": "11.14.0", + "@emotion/styled": "11.14.1", + "@mui/icons-material": "7.3.5", + "@mui/material": "7.3.5", + "@popperjs/core": "2.11.8", + "@radix-ui/react-accordion": "1.2.3", + "@radix-ui/react-alert-dialog": "1.1.6", + "@radix-ui/react-aspect-ratio": "1.1.2", + "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "1.1.4", + "@radix-ui/react-collapsible": "1.1.3", + "@radix-ui/react-context-menu": "2.2.6", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "2.1.6", + "@radix-ui/react-hover-card": "1.1.6", + "@radix-ui/react-label": "2.1.2", + "@radix-ui/react-menubar": "1.1.6", + "@radix-ui/react-navigation-menu": "1.2.5", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-progress": "1.1.2", + "@radix-ui/react-radio-group": "1.2.3", + "@radix-ui/react-scroll-area": "1.2.3", + "@radix-ui/react-select": "2.1.6", + "@radix-ui/react-separator": "1.1.2", + "@radix-ui/react-slider": "1.2.3", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-switch": "1.1.3", + "@radix-ui/react-tabs": "1.1.3", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-toggle-group": "1.1.2", + "@radix-ui/react-tooltip": "1.1.8", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "date-fns": "3.6.0", + "embla-carousel-react": "8.6.0", + "input-otp": "1.4.2", + "lucide-react": "0.487.0", + "motion": "12.23.24", + "next-themes": "0.4.6", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-day-picker": "8.10.2", + "react-dom": "^19.2.5", + "react-hook-form": "7.55.0", + "react-resizable-panels": "2.1.7", + "react-router": "7.13.0", + "recharts": "2.15.2", + "sonner": "2.0.3", + "tailwind-merge": "3.2.0", + "tw-animate-css": "1.3.8", + "vaul": "1.1.2" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "4.1.12", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.5", - "autoprefixer": "^10.5.0", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", "jsdom": "^29.1.1", - "postcss": "^8.5.12", "prettier": "^3.8.3", - "tailwindcss": "^3.4.19", - "vite": "^8.0.10", + "sharp": "^0.34.5", + "tailwindcss": "4.1.12", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", + "vite": "^6.4.2", "vitest": "^4.1.5" } }, "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", "dev": true, "license": "MIT" }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -109,7 +146,6 @@ "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", @@ -121,9 +157,9 @@ } }, "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==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { @@ -161,11 +197,17 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/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/@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", @@ -199,7 +241,6 @@ "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" @@ -209,7 +250,6 @@ "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", @@ -237,11 +277,20 @@ "@babel/core": "^7.0.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-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" @@ -251,7 +300,6 @@ "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" @@ -282,10 +330,9 @@ } }, "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, + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -297,11 +344,42 @@ "node": ">=6.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/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" @@ -311,7 +389,6 @@ "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", @@ -326,7 +403,6 @@ "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", @@ -345,7 +421,6 @@ "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", @@ -355,6 +430,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -389,9 +474,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -413,9 +498,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", "dev": true, "funding": [ { @@ -430,7 +515,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -464,9 +549,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", "dev": true, "funding": [ { @@ -508,18 +593,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", @@ -531,357 +604,3338 @@ "tslib": "^2.4.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.23.5", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", - "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.5", - "debug": "^4.3.1", - "minimatch": "^10.2.4" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "@emotion/memoize": "^0.9.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", - "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", "dependencies": { - "@eslint/core": "^1.2.1" + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@eslint/core": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", - "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" } }, - "node_modules/@eslint/js": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", - "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", - "dev": true, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://eslint.org/donate" + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" }, "peerDependencies": { - "eslint": "^10.0.0" + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" }, "peerDependenciesMeta": { - "eslint": { + "@types/react": { "optional": true } } }, - "node_modules/@eslint/object-schema": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", - "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^1.2.1", - "levn": "^0.4.1" - }, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" + "node": ">=18" } }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/types": "^0.15.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" - }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "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==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" } }, - "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==", + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "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==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "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==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } }, - "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==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 8" + "node": ">=18" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 8" + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.11.tgz", + "integrity": "sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.5.tgz", + "integrity": "sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.3.5", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", + "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.5", + "@mui/system": "^7.3.5", + "@mui/types": "^7.4.8", + "@mui/utils": "^7.3.5", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.3.5", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", + "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/utils": "^7.3.11", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.3.10", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", + "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", + "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/private-theming": "^7.3.11", + "@mui/styled-engine": "^7.3.10", + "@mui/types": "^7.4.12", + "@mui/utils": "^7.3.11", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", + "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.3.11", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", + "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "@mui/types": "^7.4.12", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "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", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.3.tgz", + "integrity": "sha512-RIQ15mrcvqIkDARJeERSuXSry2N8uYnxkdDetpfmalT/+0ntOXLkFOsh9iwlAsCv+qcmhZjbdJogIm6WBa6c4A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collapsible": "1.1.3", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", + "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", + "integrity": "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.2.tgz", + "integrity": "sha512-TaJxYoCpxJ7vfEkv2PTNox/6zzmpKXT6ewvCuf2tTOIVN45/Jahhlld29Yw4pciOXS2Xq91/rSGEdmEnUWZCqA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.3.tgz", + "integrity": "sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", + "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.3.tgz", + "integrity": "sha512-jFSerheto1X03MUC0g6R7LedNW9EEGWdg9W1+MlpkMLwGkgkbUXLPBH/KIuWKXUoeYRVY11llqbTBDzuLg7qrw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.6.tgz", + "integrity": "sha512-aUP99QZ3VU84NPsHeaFt4cQUNgJqFsLLOt/RbbWXszZ6MP0DpDyjkFZORr4RpAEx3sUBk+Kc8h13yGtC5Qw8dg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz", + "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", + "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz", + "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz", + "integrity": "sha512-E4ozl35jq0VRlrdc4dhHrNSV0JqBb4Jy73WAhBEK7JoYnQ83ED5r0Rb/XdVKw89ReAJN38N492BAPBZQ57VmqQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.6.tgz", + "integrity": "sha512-FHq7+3DlXwh/7FOM4i0G4bC4vPjiq89VEEvNF4VMLchGnaUuUbE5uKXMUCjdKaOghEEMeiKa5XCa2Pk4kteWmg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.5.tgz", + "integrity": "sha512-myMHHQUZ3ZLTi8W381/Vu43Ia0NqakkQZ2vzynMmTUtQQ9kNkjzhOwkZC9TAM5R07OZUVIQyHC06f/9JZJpvvA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", + "integrity": "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz", + "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz", + "integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz", + "integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz", + "integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.3.tgz", + "integrity": "sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", + "integrity": "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz", + "integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz", + "integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.2.tgz", + "integrity": "sha512-lntKchNWx3aCHuWKiDY+8WudiegQvBpDRAYL8dKLRvKEH8VOpl0XX6SSU/bUBqIRJbcTy4+MW06Wv8vgp10rzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.2.tgz", + "integrity": "sha512-JBm6s6aVG/nwuY5eadhU2zDi/IwYS0sDM5ZWb4nymv/hn3hZdkw+gENn0LP4iY1yCd7+bgJaCwueMYJIU3vk4A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", + "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.12.tgz", + "integrity": "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.17", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.12" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@tailwindcss/oxide": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.12.tgz", + "integrity": "sha512-gM5EoKHW/ukmlEtphNwaGx45fGoEmP10v51t9unv55voWh6WrOL19hfuIdo2FjxIaZzw776/BUQg7Pck++cIVw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "detect-libc": "^2.0.4", + "tar": "^7.4.3" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-arm64": "4.1.12", + "@tailwindcss/oxide-darwin-x64": "4.1.12", + "@tailwindcss/oxide-freebsd-x64": "4.1.12", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.12", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.12", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.12", + "@tailwindcss/oxide-linux-x64-musl": "4.1.12", + "@tailwindcss/oxide-wasm32-wasi": "4.1.12", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.12", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.12" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.12.tgz", + "integrity": "sha512-oNY5pq+1gc4T6QVTsZKwZaGpBb2N1H1fsc1GD4o7yinFySqIuRZ2E4NvGasWc6PhYJwGK2+5YT1f9Tp80zUQZQ==", "cpu": [ "arm64" ], @@ -892,13 +3946,13 @@ "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.12.tgz", + "integrity": "sha512-cq1qmq2HEtDV9HvZlTtrj671mCdGB93bVY6J29mwCyaMYCP/JaUBXxrQQQm7Qn33AXXASPUb2HFZlWiiHWFytw==", "cpu": [ "arm64" ], @@ -909,13 +3963,13 @@ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.12.tgz", + "integrity": "sha512-6UCsIeFUcBfpangqlXay9Ffty9XhFH1QuUFn0WV83W8lGdX8cD5/+2ONLluALJD5+yJ7k8mVtwy3zMZmzEfbLg==", "cpu": [ "x64" ], @@ -926,13 +3980,13 @@ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.12.tgz", + "integrity": "sha512-JOH/f7j6+nYXIrHobRYCtoArJdMJh5zy5lr0FV0Qu47MID/vqJAY3r/OElPzx1C/wdT1uS7cPq+xdYYelny1ww==", "cpu": [ "x64" ], @@ -943,13 +3997,13 @@ "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.12.tgz", + "integrity": "sha512-v4Ghvi9AU1SYgGr3/j38PD8PEe6bRfTnNSUE3YCMIRrrNigCFtHZ2TCm8142X8fcSqHBZBceDx+JlFJEfNg5zQ==", "cpu": [ "arm" ], @@ -960,13 +4014,13 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.12.tgz", + "integrity": "sha512-YP5s1LmetL9UsvVAKusHSyPlzSRqYyRB0f+Kl/xcYQSPLEw/BvGfxzbH+ihUciePDjiXwHh+p+qbSP3SlJw+6g==", "cpu": [ "arm64" ], @@ -977,13 +4031,13 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.12.tgz", + "integrity": "sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==", "cpu": [ "arm64" ], @@ -994,47 +4048,13 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.12.tgz", + "integrity": "sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==", "cpu": [ "x64" ], @@ -1045,13 +4065,13 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.12.tgz", + "integrity": "sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==", "cpu": [ "x64" ], @@ -1062,30 +4082,21 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.12.tgz", + "integrity": "sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], @@ -1093,18 +4104,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=14.0.0" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", + "integrity": "sha512-iGLyD/cVP724+FGtMWslhcFyg4xyYyM+5F4hGvKA7eifPkXHRAUDFaimu53fpNg9X8dfP75pXx/zFt/jlNF+lg==", "cpu": [ "arm64" ], @@ -1115,13 +4129,13 @@ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.12.tgz", + "integrity": "sha512-NKIh5rzw6CpEodv/++r0hGLlfgT/gFN+5WNdZtvh6wpU2BpGNgdjvj6H2oFc8nCM839QM1YOhjpgbAONUb4IxA==", "cpu": [ "x64" ], @@ -1132,22 +4146,23 @@ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 10" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "node_modules/@tailwindcss/vite": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.12.tgz", + "integrity": "sha512-4pt0AMFDx7gzIrAOIYgYP0KCBuKWqyW8ayrdiLEjoJTT4pKTjrzG/e4uzWtTLDziC+66R9wbUqZBccJalSE5vQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.12", + "@tailwindcss/oxide": "4.1.12", + "tailwindcss": "4.1.12" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } }, "node_modules/@testing-library/dom": { "version": "10.4.1", @@ -1204,142 +4219,528 @@ "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5" + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "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==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/@types/esrecurse": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", - "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "node_modules/@typescript-eslint/types": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "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==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, - "license": "MIT" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "node_modules/@typescript-eslint/utils": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", "dev": true, "license": "MIT", "dependencies": { - "csstype": "^3.2.2" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", "dev": true, "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" + "dependencies": { + "@typescript-eslint/types": "8.59.4", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { + "@vitest/browser": { "optional": true } } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1348,13 +4749,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1375,9 +4776,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { @@ -1388,13 +4789,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -1402,14 +4803,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1418,9 +4819,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -1428,13 +4829,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", - "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz", + "integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.7", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -1446,17 +4847,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.5" + "vitest": "4.1.7" } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1464,6 +4865,13 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/utils/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/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1529,47 +4937,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "tslib": "^2.0.0" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/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": ">=10" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -1590,41 +4969,38 @@ "node": ">=12" } }, - "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" }, "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">=10", + "npm": ">=6" } }, "node_modules/balanced-match": { @@ -1638,9 +5014,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", - "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "version": "2.10.31", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", + "integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1660,23 +5036,10 @@ "require-from-string": "^2.0.2" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "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==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1686,19 +5049,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1733,20 +5083,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=6" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -1774,60 +5123,97 @@ "node": ">=18" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, "engines": { - "node": ">= 8.10.0" + "node": ">=18" }, "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" }, "engines": { - "node": ">= 6" + "node": ">=10" } }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", "engines": { - "node": ">= 10" + "node": ">= 6" } }, - "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/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1864,67 +5250,12 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "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/d3": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", - "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "license": "ISC", - "dependencies": { - "d3-array": "3", - "d3-axis": "3", - "d3-brush": "3", - "d3-chord": "3", - "d3-color": "3", - "d3-contour": "4", - "d3-delaunay": "6", - "d3-dispatch": "3", - "d3-drag": "3", - "d3-dsv": "3", - "d3-ease": "3", - "d3-fetch": "3", - "d3-force": "3", - "d3-format": "3", - "d3-geo": "3", - "d3-hierarchy": "3", - "d3-interpolate": "3", - "d3-path": "3", - "d3-polygon": "3", - "d3-quadtree": "3", - "d3-random": "3", - "d3-scale": "4", - "d3-scale-chromatic": "3", - "d3-selection": "3", - "d3-shape": "3", - "d3-time": "3", - "d3-time-format": "4", - "d3-timer": "3", - "d3-transition": "3", - "d3-zoom": "3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -1937,43 +5268,6 @@ "node": ">=12" } }, - "node_modules/d3-axis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", - "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-brush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", - "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "3", - "d3-transition": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-chord": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", - "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "license": "ISC", - "dependencies": { - "d3-path": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -1983,77 +5277,6 @@ "node": ">=12" } }, - "node_modules/d3-contour": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", - "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "license": "ISC", - "dependencies": { - "d3-array": "^3.2.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-delaunay": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", - "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "license": "ISC", - "dependencies": { - "delaunator": "5" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dsv": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", - "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "license": "ISC", - "dependencies": { - "commander": "7", - "iconv-lite": "0.6", - "rw": "1" - }, - "bin": { - "csv2json": "bin/dsv2json.js", - "csv2tsv": "bin/dsv2dsv.js", - "dsv2dsv": "bin/dsv2dsv.js", - "dsv2json": "bin/dsv2json.js", - "json2csv": "bin/json2dsv.js", - "json2dsv": "bin/json2dsv.js", - "json2tsv": "bin/json2dsv.js", - "tsv2csv": "bin/dsv2dsv.js", - "tsv2json": "bin/dsv2json.js" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -2063,32 +5286,6 @@ "node": ">=12" } }, - "node_modules/d3-fetch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", - "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "license": "ISC", - "dependencies": { - "d3-dsv": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", - "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -2098,27 +5295,6 @@ "node": ">=12" } }, - "node_modules/d3-geo": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", - "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2.5.0 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -2140,33 +5316,6 @@ "node": ">=12" } }, - "node_modules/d3-polygon": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", - "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-quadtree": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", - "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-random": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", - "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -2183,28 +5332,6 @@ "node": ">=12" } }, - "node_modules/d3-scale-chromatic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", - "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-interpolate": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -2250,41 +5377,6 @@ "node": ">=12" } }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "license": "ISC", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2299,11 +5391,20 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "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" @@ -2324,6 +5425,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2331,15 +5438,6 @@ "dev": true, "license": "MIT" }, - "node_modules/delaunator": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", - "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", - "license": "ISC", - "dependencies": { - "robust-predicates": "^3.0.2" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2360,18 +5458,10 @@ "node": ">=8" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -2382,13 +5472,65 @@ "license": "MIT", "peer": true }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.344", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", - "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", "dev": true, "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2402,11 +5544,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "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" @@ -2419,6 +5569,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2433,7 +5625,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2443,16 +5634,16 @@ } }, "node_modules/eslint": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", - "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.5.5", + "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", @@ -2634,6 +5825,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2647,38 +5844,17 @@ "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-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=6.0.0" } }, "node_modules/fast-json-stable-stringify": { @@ -2695,16 +5871,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2724,9 +5890,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "dev": true, "license": "MIT" }, @@ -2743,18 +5909,11 @@ "node": ">=16.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" }, "node_modules/find-up": { "version": "5.0.0", @@ -2794,26 +5953,31 @@ "dev": true, "license": "ISC" }, - "node_modules/format": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", - "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", "license": "MIT", - "engines": { - "node": "*" + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, "node_modules/fsevents": { @@ -2835,7 +5999,6 @@ "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" @@ -2851,6 +6014,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2865,9 +6037,9 @@ } }, "node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -2877,11 +6049,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2907,6 +6095,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2920,17 +6123,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" }, "node_modules/ignore": { "version": "5.3.2", @@ -2942,6 +6140,22 @@ "node": ">= 4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2962,6 +6176,16 @@ "node": ">=8" } }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2971,27 +6195,19 @@ "node": ">=12" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" }, "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, + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -3023,16 +6239,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3047,21 +6253,59 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "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==", - "dev": true, "license": "MIT" }, "node_modules/jsdom": { @@ -3106,9 +6350,9 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3119,7 +6363,6 @@ "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" @@ -3135,6 +6378,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3187,9 +6436,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3203,44 +6452,22 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", "cpu": [ "arm64" ], @@ -3259,9 +6486,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", "cpu": [ "x64" ], @@ -3280,9 +6507,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", "cpu": [ "x64" ], @@ -3301,9 +6528,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", "cpu": [ "arm" ], @@ -3322,9 +6549,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", "cpu": [ "arm64" ], @@ -3343,9 +6570,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", "cpu": [ "arm64" ], @@ -3364,9 +6591,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", "cpu": [ "x64" ], @@ -3385,9 +6612,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", "cpu": [ "x64" ], @@ -3406,9 +6633,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", "cpu": [ "arm64" ], @@ -3427,9 +6654,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", "cpu": [ "x64" ], @@ -3447,24 +6674,10 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -3483,6 +6696,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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==", + "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", @@ -3493,6 +6724,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.487.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz", + "integrity": "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -3514,50 +6754,54 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "semver": "^7.5.3" }, "engines": { - "node": ">=8.6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/micromatch/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==", + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=10" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -3584,6 +6828,70 @@ "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/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", + "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.24", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -3598,25 +6906,12 @@ "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/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } + "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==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3639,43 +6934,35 @@ "dev": true, "license": "MIT" }, - "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", - "dev": true, - "license": "MIT" + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/node-releases": { + "version": "2.0.45", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.45.tgz", + "integrity": "sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3737,6 +7024,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3774,9 +7091,17 @@ "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-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3788,7 +7113,6 @@ "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": { @@ -3804,30 +7128,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3845,7 +7149,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3853,140 +7157,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4029,6 +7199,31 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4039,92 +7234,243 @@ "node": ">=6" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "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" - } - ], + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz", + "integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-hook-form": { + "version": "7.55.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.55.0.tgz", + "integrity": "sha512-XRnjsH3GVMQz1moZTW53MxfoWN7aDpUg/GpVNc4A3eXRVNdGXfbzJ4vM4aLQ8g6XCUh1nIbx70aaNCl7kxnjog==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "license": "MIT" }, - "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz", + "integrity": "sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", "license": "MIT", - "peer": true + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", "dependencies": { - "pify": "^2.3.0" + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "node_modules/recharts": { + "version": "2.15.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.2.tgz", + "integrity": "sha512-xv9lVztv3ingk7V3Jf05wfAZbM9Q2umJzu5t/cfnAK7LUslNrGT7LPBr74G+ok8kSCeFMaePmWMg0rcYOnczTw==", "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" }, "engines": { - "node": ">=8.10.0" + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/readdirp/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, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "dependencies": { + "decimal.js-light": "^2.4.1" } }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -4153,7 +7499,6 @@ "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", @@ -4171,98 +7516,65 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "license": "MIT", "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" + "node": ">=4" } }, - "node_modules/robust-predicates": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", - "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", - "license": "Unlicense" - }, - "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@types/estree": "1.0.8" }, "bin": { - "rolldown": "bin/cli.mjs" + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", - "dev": true, - "license": "MIT" - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "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", - "dependencies": { - "queue-microtask": "^1.2.2" + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" } }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "license": "BSD-3-Clause" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "node_modules/rollup/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/saxes": { @@ -4294,6 +7606,70 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4339,6 +7715,25 @@ "node": ">=18" } }, + "node_modules/sonner": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", + "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4376,44 +7771,29 @@ "node": ">=8" } }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" + "has-flag": "^4.0.0" }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=8" } }, "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" @@ -4429,67 +7809,70 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.2.0.tgz", + "integrity": "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz", + "integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, "engines": { - "node": ">=14.0.0" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "any-promise": "^1.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=0.8" + "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4554,19 +7937,6 @@ "dev": true, "license": "MIT" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -4603,20 +7973,33 @@ "node": ">=20" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, - "license": "Apache-2.0" + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.8.tgz", + "integrity": "sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } }, "node_modules/type-check": { "version": "0.4.0", @@ -4631,6 +8014,44 @@ "node": ">= 0.8.0" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.4", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", + "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.4", + "@typescript-eslint/parser": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -4682,31 +8103,103 @@ "punycode": "^2.1.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -4715,15 +8208,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -4732,18 +8224,15 @@ "@types/node": { "optional": true }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, "jiti": { "optional": true }, "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, @@ -4768,19 +8257,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4808,12 +8297,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -4972,6 +8461,24 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -4986,9 +8493,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", "funding": { diff --git a/frontend/package.json b/frontend/package.json index ae67c271..1c3d5956 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,34 +10,86 @@ "preview": "vite preview", "format": "prettier --write src/", "format:check": "prettier --check src/", + "typecheck": "tsc --noEmit", "test": "vitest", "test:watch": "vitest --watch", - "test:coverage": "vitest --coverage" + "test:coverage": "vitest --coverage", + "compress:landing-assets": "node scripts/compress-landing-assets.mjs" }, "dependencies": { - "d3": "^7.9.0", - "format": "^0.2.2", + "@emotion/react": "11.14.0", + "@emotion/styled": "11.14.1", + "@mui/icons-material": "7.3.5", + "@mui/material": "7.3.5", + "@popperjs/core": "2.11.8", + "@radix-ui/react-accordion": "1.2.3", + "@radix-ui/react-alert-dialog": "1.1.6", + "@radix-ui/react-aspect-ratio": "1.1.2", + "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "1.1.4", + "@radix-ui/react-collapsible": "1.1.3", + "@radix-ui/react-context-menu": "2.2.6", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "2.1.6", + "@radix-ui/react-hover-card": "1.1.6", + "@radix-ui/react-label": "2.1.2", + "@radix-ui/react-menubar": "1.1.6", + "@radix-ui/react-navigation-menu": "1.2.5", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-progress": "1.1.2", + "@radix-ui/react-radio-group": "1.2.3", + "@radix-ui/react-scroll-area": "1.2.3", + "@radix-ui/react-select": "2.1.6", + "@radix-ui/react-separator": "1.1.2", + "@radix-ui/react-slider": "1.2.3", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-switch": "1.1.3", + "@radix-ui/react-tabs": "1.1.3", + "@radix-ui/react-toggle": "1.1.2", + "@radix-ui/react-toggle-group": "1.1.2", + "@radix-ui/react-tooltip": "1.1.8", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "date-fns": "3.6.0", + "embla-carousel-react": "8.6.0", + "input-otp": "1.4.2", + "lucide-react": "0.487.0", + "motion": "12.23.24", + "next-themes": "0.4.6", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-day-picker": "8.10.2", + "react-dom": "^19.2.5", + "react-hook-form": "7.55.0", + "react-resizable-panels": "2.1.7", + "react-router": "7.13.0", + "recharts": "2.15.2", + "sonner": "2.0.3", + "tailwind-merge": "3.2.0", + "tw-animate-css": "1.3.8", + "vaul": "1.1.2" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "4.1.12", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.1.5", "@vitest/ui": "^4.1.5", - "autoprefixer": "^10.5.0", "eslint": "^10.2.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", "jsdom": "^29.1.1", - "postcss": "^8.5.12", "prettier": "^3.8.3", - "tailwindcss": "^3.4.19", - "vite": "^8.0.10", + "sharp": "^0.34.5", + "tailwindcss": "4.1.12", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.3", + "vite": "^6.4.2", "vitest": "^4.1.5" } } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js deleted file mode 100644 index 2e7af2b7..00000000 --- a/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/frontend/scripts/compress-landing-assets.mjs b/frontend/scripts/compress-landing-assets.mjs new file mode 100644 index 00000000..fd4c91c2 --- /dev/null +++ b/frontend/scripts/compress-landing-assets.mjs @@ -0,0 +1,66 @@ +/** + * Compress landing PNG imports: WebP output, cap width for huge screenshots. + * Run from repo root: node frontend/scripts/compress-landing-assets.mjs + */ +import { readdir, stat, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); +const IMPORTS_ROOT = join(__dirname, "../src/landing/imports"); +const MAX_WIDTH = 2048; +const WEBP_QUALITY = 88; + +async function walk(dir, out = []) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const p = join(dir, e.name); + if (e.isDirectory()) await walk(p, out); + else if (e.name.toLowerCase().endsWith(".png")) out.push(p); + } + return out; +} + +async function convertOne(pngPath) { + const webpPath = pngPath.replace(/\.png$/i, ".webp"); + const before = (await stat(pngPath)).size; + const meta = await sharp(pngPath).metadata(); + let img = sharp(pngPath); + if (meta.width && meta.width > MAX_WIDTH) { + img = img.resize(MAX_WIDTH, null, { + fit: "inside", + withoutEnlargement: true, + }); + } + await img + .webp({ + quality: WEBP_QUALITY, + alphaQuality: 100, + effort: 6, + }) + .toFile(webpPath); + await unlink(pngPath); + const after = (await stat(webpPath)).size; + return { pngPath, webpPath, before, after }; +} + +async function main() { + const pngs = await walk(IMPORTS_ROOT); + let saved = 0; + for (const p of pngs) { + const { before, after } = await convertOne(p); + saved += Math.max(0, before - after); + console.log( + `${p.replace(IMPORTS_ROOT, "")}: ${(before / 1024).toFixed(0)}KB → ${(after / 1024).toFixed(0)}KB`, + ); + } + console.log(`Done. ${pngs.length} files. ~${(saved / 1024 / 1024).toFixed(2)} MB smaller.`); +} + +try { + await main(); +} catch (e) { + console.error(e); + process.exit(1); +} diff --git a/frontend/scripts/generate-coverage-badge.sh b/frontend/scripts/generate-coverage-badge.sh new file mode 100755 index 00000000..22d6dd38 --- /dev/null +++ b/frontend/scripts/generate-coverage-badge.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +set -e + +echo "Generating frontend coverage badge..." + +# Check if coverage-final.json exists +if [ ! -f "coverage/coverage-final.json" ]; then + echo "coverage/coverage-final.json not found. Running tests first..." + npm run test:coverage -- --run +fi + +# Extract coverage percentage from JSON (actual Vitest format) +COVERAGE=$(python3 << 'EOF' +import json + +with open('coverage/coverage-final.json') as f: + data = json.load(f) + +total_lines = 0 +covered_lines = 0 + +for file_path, file_data in data.items(): + if isinstance(file_data, dict) and 's' in file_data: + statements = file_data['s'] + if isinstance(statements, dict): + total_lines += len(statements) + covered_lines += sum(1 for count in statements.values() if count > 0) + +if total_lines > 0: + coverage = (covered_lines / total_lines) * 100 +else: + coverage = 0 + +print(f'{coverage:.1f}') +EOF +) + +echo "Coverage: ${COVERAGE}%" + +# Determine color based on coverage (using bash arithmetic) +COVERAGE_INT=${COVERAGE%.*} +if [ "$COVERAGE_INT" -ge 80 ]; then + COLOR="green" +elif [ "$COVERAGE_INT" -ge 70 ]; then + COLOR="yellow" +else + COLOR="red" +fi + +# Generate badge using shields.io +curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" -o coverage-badge.svg + +if [ -f "coverage-badge.svg" ]; then + echo "Badge saved: coverage-badge.svg" + echo "Coverage: ${COVERAGE}% (${COLOR})" + ls -lh coverage-badge.svg +else + echo "Failed to generate badge" + exit 1 +fi diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx deleted file mode 100644 index d710a965..00000000 --- a/frontend/src/App.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from "react"; - -function App() { - const [count, setCount] = useState(0); - - return ( -
-
-

🎮 Vantage Point

-

- Spatial Intelligence Platform for Competitive Gamers -

-
- -
-
-

Welcome to Vantage Point

-

- Transform your gameplay through advanced positioning analysis. -

- -
- -
-
-

📍 Spatial Tracking

-

- Process coordinate data for deaths and kills -

-
-
-

🤖 AI Positioning

-

- ML predictions for optimal positioning -

-
-
-

👻 Ghost Player

-

Real-time overlay recommendations

-
-
-

📊 Analytics

-

Identify patterns and mistakes

-
-
-
- -
-

- Backend API:{" "} - - http://localhost:8000 - -

-
-
- ); -} - -export default App; diff --git a/frontend/src/__tests__/App.test.jsx b/frontend/src/__tests__/App.test.jsx deleted file mode 100644 index 588608ab..00000000 --- a/frontend/src/__tests__/App.test.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { render } from '@testing-library/react' -import App from '../App' - -describe('App Component', () => { - it('should render without crashing', () => { - const { container } = render() - expect(container.firstChild).not.toBeNull() - }) -}) diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx new file mode 100644 index 00000000..a395cceb --- /dev/null +++ b/frontend/src/__tests__/App.test.tsx @@ -0,0 +1,10 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import App from "../landing/app/App"; + +describe("App Component", () => { + it("should render without crashing", () => { + const { container } = render(); + expect(container.firstChild).not.toBeNull(); + }); +}); diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index b5c61c95..00000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; diff --git a/frontend/src/landing/app/App.tsx b/frontend/src/landing/app/App.tsx new file mode 100644 index 00000000..0b7d8ae0 --- /dev/null +++ b/frontend/src/landing/app/App.tsx @@ -0,0 +1,11 @@ +import { RouterProvider } from "react-router"; +import { AuthProvider } from "./context/AuthContext"; +import { router } from "./routes"; + +export default function App() { + return ( + + + + ); +} diff --git a/frontend/src/landing/app/api/auth.ts b/frontend/src/landing/app/api/auth.ts new file mode 100644 index 00000000..30418797 --- /dev/null +++ b/frontend/src/landing/app/api/auth.ts @@ -0,0 +1,34 @@ +import { apiFetchPublic } from "./client"; +import { setStoredTokens } from "../lib/tokens"; +import type { TokenResponse } from "../types/auth"; + +export interface RegisterPayload { + readonly email: string; + readonly display_name: string; + readonly password: string; +} + +export interface LoginPayload { + readonly email: string; + readonly password: string; +} + +async function storeTokensFromResponse(tokens: TokenResponse): Promise { + setStoredTokens(tokens.access_token, tokens.refresh_token); +} + +export async function registerUser(payload: RegisterPayload): Promise { + const tokens = await apiFetchPublic("/api/v1/auth/register", { + method: "POST", + body: JSON.stringify(payload), + }); + await storeTokensFromResponse(tokens); +} + +export async function loginUser(payload: LoginPayload): Promise { + const tokens = await apiFetchPublic("/api/v1/auth/login", { + method: "POST", + body: JSON.stringify(payload), + }); + await storeTokensFromResponse(tokens); +} diff --git a/frontend/src/landing/app/api/client.ts b/frontend/src/landing/app/api/client.ts new file mode 100644 index 00000000..db8dcf24 --- /dev/null +++ b/frontend/src/landing/app/api/client.ts @@ -0,0 +1,168 @@ +import { + clearStoredTokens, + getStoredTokens, + setStoredTokens, +} from "../lib/tokens"; +import type { ApiErrorBody, TokenResponse } from "../types/auth"; + +const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +async function parseErrorMessage(response: Response): Promise { + try { + const body = (await response.json()) as ApiErrorBody; + if (typeof body.detail === "string") { + return body.detail; + } + if (Array.isArray(body.detail) && body.detail[0]?.msg) { + return body.detail[0].msg; + } + } catch { + // ignore JSON parse errors + } + return response.statusText || "Request failed"; +} + +let refreshInFlight: Promise | null = null; + +async function refreshAccessToken(): Promise { + const { refreshToken } = getStoredTokens(); + if (!refreshToken) { + return false; + } + + const response = await fetch(`${API_URL}/api/v1/auth/refresh`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (!response.ok) { + clearStoredTokens(); + return false; + } + + const tokens = (await response.json()) as TokenResponse; + setStoredTokens(tokens.access_token, tokens.refresh_token); + return true; +} + +export async function apiFetch( + path: string, + options: RequestInit = {}, + retryOnUnauthorized = true, +): Promise { + const { accessToken } = getStoredTokens(); + const headers = new Headers(options.headers); + if (!headers.has("Content-Type") && options.body) { + headers.set("Content-Type", "application/json"); + } + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + let response = await fetch(`${API_URL}${path}`, { ...options, headers }); + + if (response.status === 401 && retryOnUnauthorized) { + if (!refreshInFlight) { + refreshInFlight = refreshAccessToken().finally(() => { + refreshInFlight = null; + }); + } + const refreshed = await refreshInFlight; + if (refreshed) { + const retryHeaders = new Headers(options.headers); + if (!retryHeaders.has("Content-Type") && options.body) { + retryHeaders.set("Content-Type", "application/json"); + } + const { accessToken: newAccess } = getStoredTokens(); + if (newAccess) { + retryHeaders.set("Authorization", `Bearer ${newAccess}`); + } + response = await fetch(`${API_URL}${path}`, { + ...options, + headers: retryHeaders, + }); + } + } + + if (!response.ok) { + throw new ApiError(response.status, await parseErrorMessage(response)); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} + +export async function apiFetchFormData( + path: string, + formData: FormData, + retryOnUnauthorized = true, +): Promise { + const { accessToken } = getStoredTokens(); + const headers = new Headers(); + if (accessToken) { + headers.set("Authorization", `Bearer ${accessToken}`); + } + + let response = await fetch(`${API_URL}${path}`, { + method: "POST", + headers, + body: formData, + }); + + if (response.status === 401 && retryOnUnauthorized) { + if (!refreshInFlight) { + refreshInFlight = refreshAccessToken().finally(() => { + refreshInFlight = null; + }); + } + const refreshed = await refreshInFlight; + if (refreshed) { + const retryHeaders = new Headers(); + const { accessToken: newAccess } = getStoredTokens(); + if (newAccess) { + retryHeaders.set("Authorization", `Bearer ${newAccess}`); + } + response = await fetch(`${API_URL}${path}`, { + method: "POST", + headers: retryHeaders, + body: formData, + }); + } + } + + if (!response.ok) { + throw new ApiError(response.status, await parseErrorMessage(response)); + } + + return (await response.json()) as T; +} + +export async function apiFetchPublic( + path: string, + options: RequestInit = {}, +): Promise { + const headers = new Headers(options.headers); + if (!headers.has("Content-Type") && options.body) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(`${API_URL}${path}`, { ...options, headers }); + if (!response.ok) { + throw new ApiError(response.status, await parseErrorMessage(response)); + } + return (await response.json()) as T; +} diff --git a/frontend/src/landing/app/api/match.ts b/frontend/src/landing/app/api/match.ts new file mode 100644 index 00000000..e72e650c --- /dev/null +++ b/frontend/src/landing/app/api/match.ts @@ -0,0 +1,113 @@ +import { apiFetch } from "./client"; +import type { + MatchDetail, + ParticipantDetail, + TeamDetail, +} from "../types/match"; + +interface ObjectivesSummaryApi { + readonly baron: number; + readonly dragon: number; + readonly rift_herald: number; + readonly tower: number; + readonly inhibitor: number; +} + +interface ParticipantDetailApi { + readonly puuid: string; + readonly riot_id: string | null; + readonly champion_id: number; + readonly champion_name: string; + readonly position: string; + readonly win: boolean; + readonly kills: number; + readonly deaths: number; + readonly assists: number; + readonly cs: number; + readonly gold_earned: number; + readonly damage_to_champions: number; + readonly vision_score: number; + readonly items: number[]; + readonly summoner_spells: number[]; + readonly is_viewer: boolean; +} + +interface ChampionBanApi { + readonly champion_id: number; + readonly champion_name: string; +} + +interface TeamDetailApi { + readonly team_id: number; + readonly win: boolean; + readonly bans: ChampionBanApi[]; + readonly objectives: ObjectivesSummaryApi; + readonly participants: ParticipantDetailApi[]; +} + +interface MatchDetailApi { + readonly match_id: string; + readonly game_creation: number; + readonly game_duration: number; + readonly game_version: string; + readonly queue_id: number; + readonly queue_label: string; + readonly map_id: number; + readonly map_label: string; + readonly teams: TeamDetailApi[]; +} + +function mapParticipant(p: ParticipantDetailApi): ParticipantDetail { + return { + puuid: p.puuid, + riot_id: p.riot_id, + champion_id: p.champion_id, + champion_name: p.champion_name, + position: p.position, + win: p.win, + kills: p.kills, + deaths: p.deaths, + assists: p.assists, + cs: p.cs, + gold_earned: p.gold_earned, + damage_to_champions: p.damage_to_champions, + vision_score: p.vision_score, + items: p.items, + summoner_spells: p.summoner_spells, + is_viewer: p.is_viewer, + }; +} + +function mapTeam(t: TeamDetailApi): TeamDetail { + return { + team_id: t.team_id, + win: t.win, + bans: t.bans.map((ban) => ({ + champion_id: ban.champion_id, + champion_name: ban.champion_name, + })), + objectives: t.objectives, + participants: t.participants.map(mapParticipant), + }; +} + +function mapMatchDetail(body: MatchDetailApi): MatchDetail { + return { + match_id: body.match_id, + game_creation: body.game_creation, + game_duration: body.game_duration, + game_version: body.game_version, + queue_id: body.queue_id, + queue_label: body.queue_label, + map_id: body.map_id, + map_label: body.map_label, + teams: body.teams.map(mapTeam), + }; +} + +export async function fetchMatchDetail(matchId: string): Promise { + const body = await apiFetch( + `/api/v1/matches/${encodeURIComponent(matchId)}`, + ); + return mapMatchDetail(body); +} diff --git a/frontend/src/landing/app/api/matches.ts b/frontend/src/landing/app/api/matches.ts new file mode 100644 index 00000000..b95187a4 --- /dev/null +++ b/frontend/src/landing/app/api/matches.ts @@ -0,0 +1,37 @@ +import { apiFetch } from "./client"; +import type { MatchHistorySummary } from "../types/match"; + +interface MatchHistorySummaryApi { + readonly match_id: string; + readonly champion_name: string; + readonly outcome: "Victory" | "Defeat"; + readonly duration_minutes: number; + readonly map_label: string; + readonly played_on: string; + readonly kills: number; + readonly deaths: number; + readonly assists: number; + readonly cs: number; + readonly position: string; +} + +function mapHistoryRow(row: MatchHistorySummaryApi): MatchHistorySummary { + return { + matchId: row.match_id, + champion_name: row.champion_name, + outcome: row.outcome, + duration_minutes: row.duration_minutes, + map_label: row.map_label, + played_on: row.played_on, + kills: row.kills, + deaths: row.deaths, + assists: row.assists, + cs: row.cs, + position: row.position, + }; +} + +export async function fetchMatchHistory(): Promise { + const rows = await apiFetch("/api/v1/matches"); + return rows.map(mapHistoryRow); +} diff --git a/frontend/src/landing/app/api/profile.ts b/frontend/src/landing/app/api/profile.ts new file mode 100644 index 00000000..7fc942e0 --- /dev/null +++ b/frontend/src/landing/app/api/profile.ts @@ -0,0 +1,119 @@ +import { leagueWildRiftCard, leagueWildRiftCover } from "../assets/profile"; +import type { + FeaturedGameSlide, + PlayerAchievement, + PlayerProfile, + RadarMetric, + RecentChampion, +} from "../types/profile"; +import { apiFetch } from "./client"; + +const PROFILE_IMAGE_KEYS: Record = { + league_wild_rift_cover: leagueWildRiftCover, + league_wild_rift_card: leagueWildRiftCard, +}; + +function resolveImageUrl(key: string): string { + return PROFILE_IMAGE_KEYS[key] ?? leagueWildRiftCover; +} + +interface RadarMetricApi { + readonly key: string; + readonly label: string; + readonly value: number; + readonly raw_label: string; +} + +interface RecentChampionApi { + readonly champion_id: number; + readonly champion_name: string; + readonly games_played: number; +} + +interface PlayerAchievementApi { + readonly id: string; + readonly label: string; + readonly description: string; + readonly source_field: string; + readonly count: number; +} + +interface FeaturedGameSlideApi { + readonly game_name: string; + readonly cover_image_key: string; + readonly card_image_key?: string; + readonly efficiency_score: number; + readonly time_spent_label: string; + readonly win_rate_label: string; + readonly kda_label: string; +} + +interface PlayerProfileApi { + readonly display_name: string; + readonly riot_id_tag: string; + readonly avatar_initials: string; + readonly avatar_url: string | null; + readonly matches_sampled: number; + readonly radar_metrics: RadarMetricApi[]; + readonly recent_champions: RecentChampionApi[]; + readonly achievements: PlayerAchievementApi[]; + readonly featured_games: FeaturedGameSlideApi[]; +} + +function mapRadar(m: RadarMetricApi): RadarMetric { + return { + key: m.key, + label: m.label, + value: m.value, + rawLabel: m.raw_label, + }; +} + +function mapFeatured(slide: FeaturedGameSlideApi): FeaturedGameSlide { + const cover = resolveImageUrl(slide.cover_image_key); + const card = slide.card_image_key + ? resolveImageUrl(slide.card_image_key) + : cover; + return { + game_name: slide.game_name, + cover_image_url: cover, + card_image_url: card, + efficiency_score: slide.efficiency_score, + time_spent_label: slide.time_spent_label, + win_rate_label: slide.win_rate_label, + kda_label: slide.kda_label, + }; +} + +function mapProfile(body: PlayerProfileApi): PlayerProfile { + return { + display_name: body.display_name, + riot_id_tag: body.riot_id_tag, + avatar_initials: body.avatar_initials, + avatar_url: body.avatar_url, + matches_sampled: body.matches_sampled, + radar_metrics: body.radar_metrics.map(mapRadar), + recent_champions: body.recent_champions.map( + (c): RecentChampion => ({ + champion_id: c.champion_id, + champion_name: c.champion_name, + games_played: c.games_played, + }), + ), + achievements: body.achievements.map( + (a): PlayerAchievement => ({ + id: a.id, + label: a.label, + description: a.description, + source_field: a.source_field, + count: a.count, + }), + ), + featured_games: body.featured_games.map(mapFeatured), + }; +} + +export async function fetchPlayerProfile(): Promise { + const body = await apiFetch("/api/v1/users/me/profile"); + return mapProfile(body); +} diff --git a/frontend/src/landing/app/api/user.ts b/frontend/src/landing/app/api/user.ts new file mode 100644 index 00000000..837bca8a --- /dev/null +++ b/frontend/src/landing/app/api/user.ts @@ -0,0 +1,52 @@ +import { apiFetch, apiFetchFormData } from "./client"; +import type { + AvatarUploadResponse, + LinkGameAccountResponse, + UserMe, +} from "../types/auth"; + +export async function getMe(): Promise { + return apiFetch("/api/v1/users/me"); +} + +export async function updateMe(payload: { + display_name: string; +}): Promise { + return apiFetch("/api/v1/users/me", { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function uploadAvatar(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + return apiFetchFormData( + "/api/v1/users/me/avatar", + formData, + ); +} + +export async function deleteAvatar(): Promise { + return apiFetch("/api/v1/users/me/avatar", { + method: "DELETE", + }); +} + +export async function linkGameAccount( + riotId: string, +): Promise { + return apiFetch("/api/v1/users/me/game-accounts", { + method: "POST", + body: JSON.stringify({ riot_id: riotId }), + }); +} + +export async function updateRiotId( + riotId: string, +): Promise { + return apiFetch("/api/v1/users/me/game-accounts", { + method: "PUT", + body: JSON.stringify({ riot_id: riotId }), + }); +} diff --git a/frontend/src/landing/app/assets/profile/icon-lightning-bolt.png b/frontend/src/landing/app/assets/profile/icon-lightning-bolt.png new file mode 100644 index 00000000..52dcb96f Binary files /dev/null and b/frontend/src/landing/app/assets/profile/icon-lightning-bolt.png differ diff --git a/frontend/src/landing/app/assets/profile/icon-time-machine.png b/frontend/src/landing/app/assets/profile/icon-time-machine.png new file mode 100644 index 00000000..ceab976a Binary files /dev/null and b/frontend/src/landing/app/assets/profile/icon-time-machine.png differ diff --git a/frontend/src/landing/app/assets/profile/index.ts b/frontend/src/landing/app/assets/profile/index.ts new file mode 100644 index 00000000..0e41c1a7 --- /dev/null +++ b/frontend/src/landing/app/assets/profile/index.ts @@ -0,0 +1,5 @@ +/** Profile featured-game assets exported from Figma (vantage-point file). */ +export { default as leagueWildRiftCover } from "./league-wild-rift-cover.png"; +export { default as leagueWildRiftCard } from "./league-wild-rift-card.png"; +export { default as iconLightningBolt } from "./icon-lightning-bolt.png"; +export { default as iconTimeMachine } from "./icon-time-machine.png"; diff --git a/frontend/src/landing/app/assets/profile/league-wild-rift-card.png b/frontend/src/landing/app/assets/profile/league-wild-rift-card.png new file mode 100644 index 00000000..eab271c0 Binary files /dev/null and b/frontend/src/landing/app/assets/profile/league-wild-rift-card.png differ diff --git a/frontend/src/landing/app/assets/profile/league-wild-rift-cover.png b/frontend/src/landing/app/assets/profile/league-wild-rift-cover.png new file mode 100644 index 00000000..eab271c0 Binary files /dev/null and b/frontend/src/landing/app/assets/profile/league-wild-rift-cover.png differ diff --git a/frontend/src/landing/app/components/AuthOnlyRoute.tsx b/frontend/src/landing/app/components/AuthOnlyRoute.tsx new file mode 100644 index 00000000..293e7a5e --- /dev/null +++ b/frontend/src/landing/app/components/AuthOnlyRoute.tsx @@ -0,0 +1,23 @@ +import { Navigate, Outlet } from "react-router"; +import { useAuth } from "../context/AuthContext"; + +/** Requires a logged-in session but does not require a linked Riot ID. */ +export default function AuthOnlyRoute() { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+

+ Loading… +

+
+ ); + } + + if (!user) { + return ; + } + + return ; +} diff --git a/frontend/src/landing/app/components/DashboardPage.tsx b/frontend/src/landing/app/components/DashboardPage.tsx new file mode 100644 index 00000000..f6acb69d --- /dev/null +++ b/frontend/src/landing/app/components/DashboardPage.tsx @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useState } from "react"; +import { + Outlet, + useLocation, + useNavigate, + useSearchParams, +} from "react-router"; +import DashboardShell, { + type DashboardSection, +} from "../../imports/Group14/Group14"; +import type { DashboardOutletContext } from "../context/dashboardLayoutContext"; +import { fetchPlayerProfile } from "../api/profile"; +import { useAuth } from "../context/AuthContext"; +import type { PlayerProfile } from "../types/profile"; + +function sectionFromPathname(pathname: string): DashboardSection { + return pathname.includes("/dashboard/profile") ? "profile" : "matches"; +} + +export default function DashboardPage() { + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const { user, logout, refreshUser } = useAuth(); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [profile, setProfile] = useState(undefined); + + const loadProfile = useCallback(async () => { + if (!user) { + setProfile(undefined); + return; + } + const data = await fetchPlayerProfile(); + setProfile(data); + }, [user]); + + const activeSection = sectionFromPathname(location.pathname); + + useEffect(() => { + const legacyMatch = searchParams.get("match"); + const legacyView = searchParams.get("view"); + if (legacyMatch) { + navigate(`/dashboard/matches/${encodeURIComponent(legacyMatch)}`, { + replace: true, + }); + return; + } + if (legacyView === "profile") { + navigate("/dashboard/profile", { replace: true }); + return; + } + if (legacyView === "matches") { + navigate("/dashboard/matches", { replace: true }); + } + }, [navigate, searchParams]); + + useEffect(() => { + let cancelled = false; + loadProfile().catch(() => { + if (!cancelled) { + setProfile(undefined); + } + }); + return () => { + cancelled = true; + }; + }, [loadProfile]); + + const refreshProfile = useCallback(async () => { + await refreshUser(); + await loadProfile(); + }, [refreshUser, loadProfile]); + + const accountAvatarUrl = profile?.avatar_url ?? user?.avatar_url ?? null; + const accountInitials = profile?.avatar_initials ?? "VP"; + + const handleLogout = () => { + logout(); + navigate("/login", { replace: true }); + }; + + const outletContext: DashboardOutletContext = { + sidebarOpen, + profile, + refreshProfile, + }; + + return ( +
+
+ setSidebarOpen((open) => !open)} + activeSection={activeSection} + onMatchesClick={() => navigate("/dashboard/matches")} + onProfileClick={() => navigate("/dashboard/profile")} + onLogout={handleLogout} + accountInitials={accountInitials} + accountAvatarUrl={accountAvatarUrl} + > + + +
+
+ ); +} diff --git a/frontend/src/landing/app/components/FeaturedGameCard.tsx b/frontend/src/landing/app/components/FeaturedGameCard.tsx new file mode 100644 index 00000000..39a6fd4c --- /dev/null +++ b/frontend/src/landing/app/components/FeaturedGameCard.tsx @@ -0,0 +1,129 @@ +import type { ReactNode } from "react"; +import { History, Swords, Trophy, Zap } from "lucide-react"; +import type { FeaturedGameSlide } from "../types/profile"; + +interface FeaturedGameCardProps { + readonly slide: FeaturedGameSlide; + readonly expanded: boolean; + readonly onToggle?: () => void; +} + +function StatRow({ + icon, + label, + value, +}: Readonly<{ + icon: ReactNode; + label: string; + value: string | number; +}>) { + return ( +
+ + {icon} + +
+ + {label} + + + {value} + +
+
+ ); +} + +function StatIconLucide({ Icon }: Readonly<{ Icon: typeof Trophy }>) { + return ; +} + +/** Closed state — Figma node 139:837 (Product Info Card). */ +function FeaturedGameCardClosed({ + slide, + onToggle, +}: Readonly<{ slide: FeaturedGameSlide; onToggle?: () => void }>) { + return ( + + ); +} + +/** Open state — Figma node 179:1051 (Group 16). */ +function FeaturedGameCardOpen({ + slide, + onToggle, +}: Readonly<{ slide: FeaturedGameSlide; onToggle?: () => void }>) { + return ( + + ); +} + +export default function FeaturedGameCard({ + slide, + expanded, + onToggle, +}: Readonly) { + if (expanded) { + return ; + } + return ; +} diff --git a/frontend/src/landing/app/components/LandingPage.tsx b/frontend/src/landing/app/components/LandingPage.tsx new file mode 100644 index 00000000..662af638 --- /dev/null +++ b/frontend/src/landing/app/components/LandingPage.tsx @@ -0,0 +1,9 @@ +import Group12 from "../../imports/Group12/Group12"; + +export default function LandingPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/landing/app/components/LinkRiotPage.tsx b/frontend/src/landing/app/components/LinkRiotPage.tsx new file mode 100644 index 00000000..e1359a82 --- /dev/null +++ b/frontend/src/landing/app/components/LinkRiotPage.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { ApiError } from "../api/client"; +import { useAuth } from "../context/AuthContext"; +import RiotIdComponent, { + type RiotIdFormProps, +} from "../../imports/RiotId/RiotId"; + +export default function LinkRiotPage() { + const navigate = useNavigate(); + const { linkRiot } = useAuth(); + const [riotId, setRiotId] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async () => { + setError(null); + const trimmed = riotId.trim(); + if (!trimmed.includes("#")) { + setError("Enter your Riot ID as Name#TAG (e.g. Player#EUW)."); + return; + } + + setLoading(true); + try { + await linkRiot(trimmed); + navigate("/loading", { replace: true }); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : "Could not link Riot ID. Please try again."; + setError(message); + } finally { + setLoading(false); + } + }; + + const formProps: RiotIdFormProps = { + riotId, + error, + loading, + onRiotIdChange: setRiotId, + onSubmit: () => void handleSubmit(), + }; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/landing/app/components/LoadingPage.tsx b/frontend/src/landing/app/components/LoadingPage.tsx new file mode 100644 index 00000000..758a3765 --- /dev/null +++ b/frontend/src/landing/app/components/LoadingPage.tsx @@ -0,0 +1,57 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router"; +import { useAuth } from "../context/AuthContext"; +import imgLogo from "../../imports/Login/798001aef0b2686ac929f8c349135d3326ab65bb.webp"; + +const MIN_DISPLAY_MS = 1500; +const DOT_DELAYS_S = [0, 0.9, 1.8] as const; + +export default function LoadingPage() { + const navigate = useNavigate(); + const { refreshUser } = useAuth(); + + useEffect(() => { + const started = Date.now(); + + void (async () => { + await refreshUser(); + const elapsed = Date.now() - started; + const remaining = Math.max(0, MIN_DISPLAY_MS - elapsed); + globalThis.setTimeout(() => { + navigate("/dashboard/matches", { replace: true }); + }, remaining); + })(); + }, [navigate, refreshUser]); + + return ( +
+
+
+ +
+

+ Vantage Point +

+ +
+
+ ); +} diff --git a/frontend/src/landing/app/components/LoginPage.tsx b/frontend/src/landing/app/components/LoginPage.tsx new file mode 100644 index 00000000..93460cde --- /dev/null +++ b/frontend/src/landing/app/components/LoginPage.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { ApiError } from "../api/client"; +import { useAuth } from "../context/AuthContext"; +import LoginComponent, { type LoginFormProps } from "../../imports/Login/Login"; + +export default function LoginPage() { + const navigate = useNavigate(); + const { login } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async () => { + setError(null); + setLoading(true); + try { + const me = await login({ email: email.trim(), password }); + if (me.has_linked_riot) { + navigate("/loading", { replace: true }); + } else { + navigate("/link-riot", { replace: true }); + } + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : "Sign in failed. Please try again."; + setError(message); + } finally { + setLoading(false); + } + }; + + const formProps: LoginFormProps = { + email, + password, + error, + loading, + onEmailChange: setEmail, + onPasswordChange: setPassword, + onSubmit: () => void handleSubmit(), + onSocialClick: () => setError("Social sign-in is coming soon."), + }; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/landing/app/components/MatchDetailView.tsx b/frontend/src/landing/app/components/MatchDetailView.tsx new file mode 100644 index 00000000..b8827b01 --- /dev/null +++ b/frontend/src/landing/app/components/MatchDetailView.tsx @@ -0,0 +1,456 @@ +import { useEffect, useState } from "react"; +import { ArrowLeft } from "lucide-react"; +import { useNavigate, useOutletContext, useParams } from "react-router"; +import type { DashboardOutletContext } from "../context/dashboardLayoutContext"; +import { fetchMatchDetail } from "../api/match"; +import { + DASHBOARD_CONTENT_HEIGHT, + getDashboardContentStyle, +} from "../lib/dashboardLayout"; +import { + championIconUrl, + itemIconUrl, + summonerSpellIconUrl, +} from "../lib/ddragon"; +import type { + MatchDetail, + ParticipantDetail, + TeamDetail, +} from "../types/match"; + +interface MatchDetailViewProps { + readonly matchId?: string; + readonly sidebarOpen?: boolean; + readonly onBack?: () => void; + readonly viewerPuuid?: string; +} + +function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +function formatGameDate(epochMs: number): string { + return new Date(epochMs).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function formatNumber(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + +function viewerParticipant( + match: MatchDetail, + viewerPuuid?: string, +): ParticipantDetail | undefined { + for (const team of match.teams) { + const found = team.participants.find( + (p) => p.is_viewer || (viewerPuuid && p.puuid === viewerPuuid), + ); + if (found) return found; + } + return undefined; +} + +function LoadingSkeleton() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +function ParticipantRow({ player }: Readonly<{ player: ParticipantDetail }>) { + const isViewer = player.is_viewer; + const rowBg = isViewer ? "bg-[#dce8fc]" : ""; + const cellBase = `py-2 ${rowBg}`; + + return ( + + +
+ +
+

+ {player.riot_id ?? player.champion_name} +

+

{player.position}

+
+
+ + + {player.kills}/{player.deaths}/{player.assists} + + + {player.cs} + + + {formatNumber(player.gold_earned)} + + + {formatNumber(player.damage_to_champions)} + + + {player.vision_score} + + +
+ {player.summoner_spells.map((spellId) => { + const url = summonerSpellIconUrl(spellId); + return url ? ( + + ) : ( + + {spellId} + + ); + })} + {player.items.map((itemId, i) => { + const url = itemIconUrl(itemId); + if (!url) { + return ( + + ); + } + return ( + + ); + })} +
+ + + ); +} + +function TeamScoreboard({ + team, + sideLabel, +}: Readonly<{ team: TeamDetail; sideLabel: string }>) { + const sideColor = team.team_id === 100 ? "text-[#4a7fd4]" : "text-[#c44a4a]"; + + return ( +
+
+

{sideLabel}

+ + {team.win ? "Victory" : "Defeat"} + +
+
+ + + + + + + + + + + + + + {team.participants.map((p) => ( + + ))} + +
PlayerKDACS + Gold + + DMG + + Vis + Build
+
+
+ ); +} + +function ObjectivesRow({ teams }: Readonly<{ teams: readonly TeamDetail[] }>) { + const labels: { key: keyof TeamDetail["objectives"]; label: string }[] = [ + { key: "dragon", label: "Dragons" }, + { key: "baron", label: "Baron" }, + { key: "rift_herald", label: "Herald" }, + { key: "tower", label: "Towers" }, + { key: "inhibitor", label: "Inhibitors" }, + ]; + + return ( +
+ {teams.map((team) => ( +
+

+ {team.team_id === 100 ? "Blue" : "Red"} objectives +

+
    + {labels.map(({ key, label }) => ( +
  • + {label} + + {team.objectives[key]} + +
  • + ))} +
+
+ ))} +
+ ); +} + +function BansRow({ teams }: Readonly<{ teams: readonly TeamDetail[] }>) { + return ( +
+ {teams.map((team) => ( +
+

+ {team.team_id === 100 ? "Blue" : "Red"} bans +

+
+ {team.bans.map((ban) => ( + {ban.champion_name} + ))} +
+
+ ))} +
+ ); +} + +export default function MatchDetailView({ + matchId: matchIdProp, + sidebarOpen: sidebarOpenProp, + onBack: onBackProp, + viewerPuuid, +}: Readonly = {}) { + const navigate = useNavigate(); + const { matchId: matchIdParam } = useParams<{ matchId: string }>(); + const outlet = useOutletContext(); + const matchId = matchIdProp ?? matchIdParam ?? ""; + const sidebarOpen = sidebarOpenProp ?? outlet?.sidebarOpen ?? true; + const onBack = + onBackProp ?? (() => navigate("/dashboard/matches", { replace: false })); + + const [match, setMatch] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const contentStyle = getDashboardContentStyle(sidebarOpen); + + useEffect(() => { + if (!matchId) { + setMatch(null); + setError(null); + return; + } + + let cancelled = false; + setLoading(true); + setError(null); + setMatch(null); + + fetchMatchDetail(matchId) + .then((data) => { + if (!cancelled) { + if (!data.teams?.length) { + setError("Could not load match"); + } else { + setMatch(data); + } + } + }) + .catch(() => { + if (!cancelled) setError("Could not load match"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [matchId, viewerPuuid]); + + const viewer = match ? viewerParticipant(match, viewerPuuid) : undefined; + const resultLabel = viewer ? (viewer.win ? "Victory" : "Defeat") : null; + const resultClass = viewer?.win ? "text-[#1e7e34]" : "text-[#c44a4a]"; + + const blueTeam = match?.teams.find((t) => t.team_id === 100); + const redTeam = match?.teams.find((t) => t.team_id === 200); + + return ( +
+
+ + +
+ {loading && ( +

+ Loading match… +

+ )} + {error && ( +

{error}

+ )} + {match && viewer && ( + <> +
+ +
+

+ {resultLabel} +

+

+ {viewer.champion_name} · {viewer.kills}/{viewer.deaths}/ + {viewer.assists} KDA +

+
+
+

+ {formatDuration(match.game_duration)} + · + {match.queue_label} + · + {match.map_label} + · + v{match.game_version} + · + {formatGameDate(match.game_creation)} +

+ + )} + {match && !viewer && ( +

+ Match details +

+ )} +
+ +
+ {loading && } + {error && !loading && ( +

+ Try again later or pick another match from your matches. +

+ )} + {match && blueTeam && redTeam && !loading && ( + <> +
+ + +
+
+

+ Objectives +

+ +
+
+

+ Bans +

+ +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/landing/app/components/MatchesListToolbar.tsx b/frontend/src/landing/app/components/MatchesListToolbar.tsx new file mode 100644 index 00000000..b9754051 --- /dev/null +++ b/frontend/src/landing/app/components/MatchesListToolbar.tsx @@ -0,0 +1,65 @@ +import { Search } from "lucide-react"; +import type { MatchFilterId, MatchSortId } from "../lib/matchListControls"; +import MatchesListToolbarMenus from "./MatchesListToolbarMenus"; + +interface MatchesListToolbarProps { + readonly searchQuery: string; + readonly onSearchQueryChange: (query: string) => void; + readonly filterId: MatchFilterId; + readonly onFilterIdChange: (filterId: MatchFilterId) => void; + readonly sortId: MatchSortId; + readonly onSortIdChange: (sortId: MatchSortId) => void; +} + +export default function MatchesListToolbar({ + searchQuery, + onSearchQueryChange, + filterId, + onFilterIdChange, + sortId, + onSortIdChange, +}: Readonly) { + return ( +
+
+ + + +
+ ); +} diff --git a/frontend/src/landing/app/components/MatchesListToolbarMenus.tsx b/frontend/src/landing/app/components/MatchesListToolbarMenus.tsx new file mode 100644 index 00000000..39eb4cf6 --- /dev/null +++ b/frontend/src/landing/app/components/MatchesListToolbarMenus.tsx @@ -0,0 +1,96 @@ +import { ArrowUpDown, Filter } from "lucide-react"; +import { + MATCH_FILTER_OPTIONS, + MATCH_SORT_OPTIONS, + matchFilterLabel, + matchSortLabel, + type MatchFilterId, + type MatchSortId, +} from "../lib/matchListControls"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +const TOOLBAR_ICON_BUTTON_CLASS = + "flex size-[40px] shrink-0 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-[#0a0a0a] hover:bg-neutral-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4a7fd4]"; + +interface MatchesListToolbarMenusProps { + readonly filterId: MatchFilterId; + readonly onFilterIdChange: (filterId: MatchFilterId) => void; + readonly sortId: MatchSortId; + readonly onSortIdChange: (sortId: MatchSortId) => void; +} + +export default function MatchesListToolbarMenus({ + filterId, + onFilterIdChange, + sortId, + onSortIdChange, +}: Readonly) { + return ( +
+ + + + + + Filter + onFilterIdChange(value as MatchFilterId)} + > + {MATCH_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + + + + + + + Sort + onSortIdChange(value as MatchSortId)} + > + {MATCH_SORT_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+ ); +} diff --git a/frontend/src/landing/app/components/MatchesListView.tsx b/frontend/src/landing/app/components/MatchesListView.tsx new file mode 100644 index 00000000..8d2f36b2 --- /dev/null +++ b/frontend/src/landing/app/components/MatchesListView.tsx @@ -0,0 +1,299 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { ChevronRight } from "lucide-react"; +import { useNavigate, useOutletContext } from "react-router"; +import type { DashboardOutletContext } from "../context/dashboardLayoutContext"; +import { + DASHBOARD_CONTENT_HEIGHT, + getDashboardContentStyle, +} from "../lib/dashboardLayout"; +import { fetchMatchHistory } from "../api/matches"; +import { + groupDashboardMatchesByDay, + type DashboardMatchListItem, + type MatchHistoryDayRow, +} from "../lib/matchHistoryGroup"; +import { + applyMatchListControls, + matchListDaySortAscending, +} from "../lib/matchListQuery"; +import { + DEFAULT_MATCH_FILTER_ID, + DEFAULT_MATCH_SORT_ID, + type MatchFilterId, + type MatchSortId, +} from "../lib/matchListControls"; +import type { MatchHistorySummary } from "../types/match"; +import MatchesListToolbar from "./MatchesListToolbar"; + +interface MatchesListViewProps { + readonly sidebarOpen?: boolean; +} + +function outcomeClass(outcome: "Victory" | "Defeat"): string { + return outcome === "Victory" ? "text-[#1e7e34]" : "text-[#c44a4a]"; +} + +interface MatchHistoryListRowProps { + readonly item: DashboardMatchListItem; + readonly onOpenMatch: (matchId: string) => void; +} + +const MATCH_ROW_GRID = + "grid w-full grid-cols-[88px_minmax(0,1fr)_72px_80px_20px] items-center gap-x-4 gap-y-1 px-4 py-3 sm:grid-cols-[88px_minmax(0,1fr)_52px_72px_72px_80px_20px]"; + +const STAT_LABEL_CLASS = + "font-['Inter:Regular',sans-serif] text-[11px] font-medium uppercase tracking-wide text-[#757575]"; + +const STAT_VALUE_CLASS = + "font-['Inter:Regular',sans-serif] text-[14px] text-[#1e1e1e] tabular-nums"; + +function matchRowAriaLabel(item: DashboardMatchListItem): string { + return `View match as ${item.champion_name}, ${item.outcome}, role ${item.roleLabel}, KDA ${item.kdaLabel}, ${item.cs} creep score, ${item.duration_minutes} minutes`; +} + +function MatchStatCell({ + children, + className = "", + hideOnMobile = false, + align = "start", +}: Readonly<{ + children: ReactNode; + className?: string; + hideOnMobile?: boolean; + align?: "start" | "end"; +}>) { + const visibility = hideOnMobile ? "hidden sm:block" : "block"; + const alignment = align === "end" ? "text-right" : "text-left"; + return ( +
+ {children} +
+ ); +} + +function MatchHistoryListHeader() { + return ( +
+ + Result + + + Champion + + + + KDA + + + + Duration + + +
+ ); +} + +function MatchHistoryListRow({ + item, + onOpenMatch, +}: Readonly) { + return ( + + ); +} + +function MatchHistoryDaySection({ + dayRow, + onOpenMatch, +}: Readonly<{ + dayRow: MatchHistoryDayRow; + onOpenMatch: (matchId: string) => void; +}>) { + return ( +
+

+ {dayRow.dateLabel} +

+
+ +
    + {dayRow.matches.map((item) => ( +
  • + +
  • + ))} +
+
+
+ ); +} + +export default function MatchesListView( + props: Readonly = {}, +) { + const { sidebarOpen: sidebarOpenProp } = props; + const navigate = useNavigate(); + const outlet = useOutletContext(); + const sidebarOpen = sidebarOpenProp ?? outlet?.sidebarOpen ?? true; + + const handleOpenMatch = (matchId: string) => { + navigate(`/dashboard/matches/${encodeURIComponent(matchId)}`); + }; + + const [allMatches, setAllMatches] = useState( + [], + ); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [filterId, setFilterId] = useState( + DEFAULT_MATCH_FILTER_ID, + ); + const [sortId, setSortId] = useState(DEFAULT_MATCH_SORT_ID); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + fetchMatchHistory() + .then((matches) => { + if (!cancelled) { + setAllMatches(matches); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Failed to load matches", + ); + setAllMatches([]); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + + const dayRows = useMemo( + () => + groupDashboardMatchesByDay( + applyMatchListControls(allMatches, { filterId, sortId, searchQuery }), + { oldestDaysFirst: matchListDaySortAscending(sortId) }, + ), + [allMatches, filterId, searchQuery, sortId], + ); + + const hasNoMatches = !loading && !error && allMatches.length === 0; + const hasNoVisibleMatches = + !loading && !error && allMatches.length > 0 && dayRows.length === 0; + + const contentStyle = getDashboardContentStyle(sidebarOpen); + + return ( +
+
+ {!loading && !error ? ( + + ) : null} + {loading ? ( +

+ Loading matches… +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {hasNoMatches ? ( +

+ No matches yet. Link your Riot ID or sign in with the seeded test + account. +

+ ) : null} + {hasNoVisibleMatches ? ( +

+ No matches match your search or filters. +

+ ) : null} +
+ {dayRows.map((dayRow) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/landing/app/components/ProfileHeaderEditor.tsx b/frontend/src/landing/app/components/ProfileHeaderEditor.tsx new file mode 100644 index 00000000..9c602d0a --- /dev/null +++ b/frontend/src/landing/app/components/ProfileHeaderEditor.tsx @@ -0,0 +1,297 @@ +import { useEffect, useRef, useState } from "react"; +import { Camera, Pencil } from "lucide-react"; +import { ApiError } from "../api/client"; +import { + deleteAvatar, + updateMe, + updateRiotId, + uploadAvatar, +} from "../api/user"; +import { resolveAvatarUrl } from "../lib/avatarUrl"; +import { parseRiotId } from "../lib/riotId"; +import type { PlayerProfile } from "../types/profile"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; + +interface ProfileHeaderEditorProps { + readonly profile: PlayerProfile; + readonly onSaved: () => Promise; +} + +export default function ProfileHeaderEditor({ + profile, + onSaved, +}: Readonly) { + const [editing, setEditing] = useState(false); + const [displayName, setDisplayName] = useState(profile.display_name); + const [riotId, setRiotId] = useState( + profile.riot_id_tag === "Not linked" ? "" : profile.riot_id_tag, + ); + const [avatarPreview, setAvatarPreview] = useState( + resolveAvatarUrl(profile.avatar_url), + ); + const [pendingFile, setPendingFile] = useState(null); + const [removeAvatar, setRemoveAvatar] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (!editing) { + setDisplayName(profile.display_name); + setRiotId( + profile.riot_id_tag === "Not linked" ? "" : profile.riot_id_tag, + ); + setAvatarPreview(resolveAvatarUrl(profile.avatar_url)); + setPendingFile(null); + setRemoveAvatar(false); + } + }, [profile, editing]); + + const resetForm = () => { + setDisplayName(profile.display_name); + setRiotId(profile.riot_id_tag === "Not linked" ? "" : profile.riot_id_tag); + setAvatarPreview(resolveAvatarUrl(profile.avatar_url)); + setPendingFile(null); + setRemoveAvatar(false); + setError(null); + }; + + const handleCancel = () => { + resetForm(); + setEditing(false); + }; + + const handleFileChange = (file: File | undefined) => { + if (!file) { + return; + } + setPendingFile(file); + setRemoveAvatar(false); + setAvatarPreview(URL.createObjectURL(file)); + }; + + const handleSave = async () => { + const trimmedName = displayName.trim(); + if (!trimmedName) { + setError("Display name is required."); + return; + } + + setSaving(true); + setError(null); + + try { + await updateMe({ display_name: trimmedName }); + + if (removeAvatar && profile.avatar_url) { + await deleteAvatar(); + } else if (pendingFile) { + const result = await uploadAvatar(pendingFile); + setAvatarPreview(resolveAvatarUrl(result.avatar_url)); + } + + const trimmedRiot = riotId.trim(); + const currentRiot = + profile.riot_id_tag === "Not linked" ? "" : profile.riot_id_tag; + if (trimmedRiot && trimmedRiot !== currentRiot) { + parseRiotId(trimmedRiot); + await updateRiotId(trimmedRiot); + } + + await onSaved(); + setEditing(false); + setPendingFile(null); + setRemoveAvatar(false); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : err instanceof Error + ? err.message + : "Could not save profile."; + setError(message); + } finally { + setSaving(false); + } + }; + + const viewAvatarSrc = resolveAvatarUrl(profile.avatar_url); + + if (!editing) { + return ( +
+
+ + {viewAvatarSrc ? ( + + ) : null} + + {profile.avatar_initials} + + +
+

+ {profile.display_name} +

+

+ {profile.riot_id_tag} +

+
+
+ +
+ ); + } + + return ( +
+
+
+ + handleFileChange(e.target.files?.[0])} + /> + + {profile.avatar_url || avatarPreview ? ( + + ) : null} +
+ +
+ + +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + +
+
+ ); +} diff --git a/frontend/src/landing/app/components/ProfileRadarChart.tsx b/frontend/src/landing/app/components/ProfileRadarChart.tsx new file mode 100644 index 00000000..01fdef15 --- /dev/null +++ b/frontend/src/landing/app/components/ProfileRadarChart.tsx @@ -0,0 +1,52 @@ +import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from "recharts"; +import type { RadarMetric } from "../types/profile"; +import { ChartContainer, type ChartConfig } from "./ui/chart"; + +interface ProfileRadarChartProps { + readonly metrics: readonly RadarMetric[]; + readonly className?: string; +} + +const chartConfig = { + performance: { + label: "Performance", + color: "#22c55e", + }, +} satisfies ChartConfig; + +export default function ProfileRadarChart({ + metrics, + className, +}: Readonly) { + const data = metrics.map((m) => ({ + metric: m.label, + value: m.value, + raw: m.rawLabel, + })); + + return ( + + + + + + + + ); +} diff --git a/frontend/src/landing/app/components/ProfileView.tsx b/frontend/src/landing/app/components/ProfileView.tsx new file mode 100644 index 00000000..05b68986 --- /dev/null +++ b/frontend/src/landing/app/components/ProfileView.tsx @@ -0,0 +1,163 @@ +import { useState } from "react"; +import { useOutletContext } from "react-router"; +import type { DashboardOutletContext } from "../context/dashboardLayoutContext"; +import { getAchievementIcon } from "../lib/achievementIcons"; +import { championIconUrl } from "../lib/ddragon"; +import type { PlayerProfile } from "../types/profile"; +import { + DASHBOARD_CONTENT_HEIGHT, + getDashboardContentStyle, +} from "../lib/dashboardLayout"; +import FeaturedGameCard from "./FeaturedGameCard"; +import ProfileHeaderEditor from "./ProfileHeaderEditor"; +import ProfileRadarChart from "./ProfileRadarChart"; +import { useAuth } from "../context/AuthContext"; + +interface ProfileViewProps { + readonly profile?: PlayerProfile; + readonly sidebarOpen?: boolean; +} + +export default function ProfileView({ + profile: profileProp, + sidebarOpen: sidebarOpenProp, +}: Readonly = {}) { + const outlet = useOutletContext(); + const { refreshUser } = useAuth(); + const profile = profileProp ?? outlet?.profile; + const sidebarOpen = sidebarOpenProp ?? outlet?.sidebarOpen ?? true; + const refreshProfile = outlet?.refreshProfile; + const [cardExpanded, setCardExpanded] = useState(true); + + const handleProfileSaved = async () => { + if (refreshProfile) { + await refreshProfile(); + } else { + await refreshUser(); + } + }; + + const contentStyle = getDashboardContentStyle(sidebarOpen); + + if (!profile) { + return ( +
+ Loading profile… +
+ ); + } + + const featured = profile.featured_games[0]; + + return ( +
+
+ + +
+

+ Last {profile.matches_sampled} matches +

+ +
+
+ +
    + {profile.radar_metrics.map((m) => ( +
  • + {m.label}:{" "} + {m.rawLabel} +
  • + ))} +
+
+ + {featured ? ( +
+
+ setCardExpanded((open) => !open)} + /> +
+
+ ) : null} +
+
+ +
+

+ Last played champions +

+
+ {profile.recent_champions.map((champ) => ( +
+ {champ.champion_name} + + {champ.games_played} + +
+ ))} +
+
+ +
+

+ Achievements +

+
+ {profile.achievements.map((a) => { + const Icon = getAchievementIcon(a.id); + return ( +
+
+ {Icon ? ( + + ) : null} + + {a.count > 99 ? "99+" : a.count} + +
+ + {a.label} + +
+ ); + })} +
+
+
+
+ ); +} diff --git a/frontend/src/landing/app/components/ProtectedRoute.tsx b/frontend/src/landing/app/components/ProtectedRoute.tsx new file mode 100644 index 00000000..47cb60ec --- /dev/null +++ b/frontend/src/landing/app/components/ProtectedRoute.tsx @@ -0,0 +1,32 @@ +import { Navigate, Outlet } from "react-router"; +import { useAuth } from "../context/AuthContext"; + +interface ProtectedRouteProps { + readonly requireRiot?: boolean; +} + +export default function ProtectedRoute({ + requireRiot = false, +}: Readonly) { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+

+ Loading… +

+
+ ); + } + + if (!user) { + return ; + } + + if (requireRiot && !user.has_linked_riot) { + return ; + } + + return ; +} diff --git a/frontend/src/landing/app/components/RegisterPage.tsx b/frontend/src/landing/app/components/RegisterPage.tsx new file mode 100644 index 00000000..1929e7e0 --- /dev/null +++ b/frontend/src/landing/app/components/RegisterPage.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { ApiError } from "../api/client"; +import { useAuth } from "../context/AuthContext"; +import RegisterComponent, { + type RegisterFormProps, +} from "../../imports/Register/Register"; + +export default function RegisterPage() { + const navigate = useNavigate(); + const { register } = useAuth(); + const [email, setEmail] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async () => { + setError(null); + if (password !== confirmPassword) { + setError("Passwords do not match."); + return; + } + if (password.length < 8) { + setError("Password must be at least 8 characters."); + return; + } + + setLoading(true); + try { + await register({ + email: email.trim(), + display_name: displayName.trim(), + password, + }); + navigate("/link-riot", { replace: true }); + } catch (err) { + const message = + err instanceof ApiError + ? err.message + : "Registration failed. Please try again."; + setError(message); + } finally { + setLoading(false); + } + }; + + const formProps: RegisterFormProps = { + email, + displayName, + password, + confirmPassword, + error, + loading, + onEmailChange: setEmail, + onDisplayNameChange: setDisplayName, + onPasswordChange: setPassword, + onConfirmPasswordChange: setConfirmPassword, + onSubmit: () => void handleSubmit(), + onSocialClick: () => setError("Social sign-in is coming soon."), + }; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/landing/app/components/UserAccountMenu.tsx b/frontend/src/landing/app/components/UserAccountMenu.tsx new file mode 100644 index 00000000..c50ffeda --- /dev/null +++ b/frontend/src/landing/app/components/UserAccountMenu.tsx @@ -0,0 +1,90 @@ +import { LogOut, User } from "lucide-react"; +import { resolveAvatarUrl } from "../lib/avatarUrl"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; + +interface UserAccountMenuProps { + readonly onProfileClick?: () => void; + readonly onLogout?: () => void; + readonly initials?: string; + readonly avatarUrl?: string | null; +} + +function ProfileAvatar({ + initials, + avatarUrl, +}: Readonly<{ initials: string; avatarUrl?: string | null }>) { + const src = resolveAvatarUrl(avatarUrl ?? undefined); + + if (src) { + return ( + + + + {initials} + + + ); + } + + return ( +
+ + + + + {initials} + +
+ ); +} + +export default function UserAccountMenu({ + onProfileClick, + onLogout, + initials = "UN", + avatarUrl = null, +}: Readonly) { + return ( + + + + + + onProfileClick?.()} + className="font-['Inter:Regular',sans-serif] cursor-pointer" + > + + Profile + + + onLogout?.()} + className="font-['Inter:Regular',sans-serif] cursor-pointer" + > + + Log out + + + + ); +} diff --git a/frontend/src/landing/app/components/figma/ImageWithFallback.tsx b/frontend/src/landing/app/components/figma/ImageWithFallback.tsx new file mode 100644 index 00000000..889d6c40 --- /dev/null +++ b/frontend/src/landing/app/components/figma/ImageWithFallback.tsx @@ -0,0 +1,41 @@ +import React, { useState } from "react"; + +const ERROR_IMG_SRC = + "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=="; + +export function ImageWithFallback( + props: React.ImgHTMLAttributes, +) { + const [didError, setDidError] = useState(false); + + const handleError = () => { + setDidError(true); + }; + + const { src, alt, style, className, ...rest } = props; + + return didError ? ( +
+
+ Unavailable +
+
+ ) : ( + {alt} + ); +} diff --git a/frontend/src/landing/app/components/ui/accordion.tsx b/frontend/src/landing/app/components/ui/accordion.tsx new file mode 100644 index 00000000..bd6b1e33 --- /dev/null +++ b/frontend/src/landing/app/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client"; + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "./utils"; + +function Accordion({ + ...props +}: React.ComponentProps) { + return ; +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className, + )} + {...props} + > + {children} + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/src/landing/app/components/ui/alert-dialog.tsx b/frontend/src/landing/app/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..875b8df4 --- /dev/null +++ b/frontend/src/landing/app/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client"; + +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "./utils"; +import { buttonVariants } from "./button"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/frontend/src/landing/app/components/ui/alert.tsx b/frontend/src/landing/app/components/ui/alert.tsx new file mode 100644 index 00000000..9c35976c --- /dev/null +++ b/frontend/src/landing/app/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "./utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/frontend/src/landing/app/components/ui/aspect-ratio.tsx b/frontend/src/landing/app/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..c16d6bcb --- /dev/null +++ b/frontend/src/landing/app/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client"; + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"; + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return ; +} + +export { AspectRatio }; diff --git a/frontend/src/landing/app/components/ui/avatar.tsx b/frontend/src/landing/app/components/ui/avatar.tsx new file mode 100644 index 00000000..c9904518 --- /dev/null +++ b/frontend/src/landing/app/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "./utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/frontend/src/landing/app/components/ui/badge.tsx b/frontend/src/landing/app/components/ui/badge.tsx new file mode 100644 index 00000000..2ccc2c44 --- /dev/null +++ b/frontend/src/landing/app/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "./utils"; + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/frontend/src/landing/app/components/ui/breadcrumb.tsx b/frontend/src/landing/app/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..8b535e47 --- /dev/null +++ b/frontend/src/landing/app/components/ui/breadcrumb.tsx @@ -0,0 +1,107 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "./utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return