commit 74ca4b38ebdf6964a9ea157aca7b4e7603664207 Author: Ryan Hill Date: Thu Nov 13 15:38:58 2025 -0600 Initial commit: GSPro Remote MVP - Phase 1 complete diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4f71e19 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,47 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Universal settings +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +# Python files +[*.py] +indent_size = 4 +max_line_length = 120 + +# TypeScript, JavaScript, JSX, TSX files +[*.{ts,tsx,js,jsx}] +indent_size = 2 +max_line_length = 100 + +# JSON, YAML files +[*.{json,yml,yaml}] +indent_size = 2 + +# HTML, CSS, SCSS files +[*.{html,css,scss}] +indent_size = 2 + +# Markdown files +[*.md] +trim_trailing_whitespace = false +max_line_length = 120 + +# PowerShell scripts +[*.{ps1,psm1}] +end_of_line = crlf + +# Windows batch files +[*.{bat,cmd}] +end_of_line = crlf + +# Makefiles +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a2239b --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +ENV/ +env.bak/ +venv.bak/ +*.egg-info/ +dist/ +build/ +*.egg +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.hypothesis/ +*.log + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +dist/ +dist-ssr/ +*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Environment +.env +.env.local +.env.production +.env.development + +# Build outputs +backend/ui/ +*.exe +*.spec +*.msi + +# Config (keep templates, ignore actual) +config.json +!config.template.json + +# OS +Thumbs.db +Desktop.ini diff --git a/GSPro App.png b/GSPro App.png new file mode 100644 index 0000000..95031ec Binary files /dev/null and b/GSPro App.png differ diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..2d5c4ca --- /dev/null +++ b/PRD.md @@ -0,0 +1,229 @@ +# Product Requirements Document (PRD) + +**Product:** GSPro Remote +**Version:** 0.1.0 (Restart Foundation) +**Author:** \[You] +**Date:** \[Today’s date] + +--- + +## 1. Executive Summary + +GSPro Remote is a companion application for **GSPro golf simulator users** running on Windows. It allows golfers to control GSPro from a secondary device (tablet, phone, or PC) via a web-based interface. + +The app’s purpose is to: + +* Simplify control of GSPro without needing a physical keyboard nearby. +* Provide intuitive UI for common simulator functions (aim, club selection, mulligans, tee box navigation, shot options, map interaction). +* Enhance immersion and ease-of-use for golf enthusiasts with GSPro setups. + +Future versions may incorporate **computer vision (OCR/marker detection)** to auto-detect and track GSPro UI elements, reducing manual setup and enabling more advanced automation. + +--- + +## 2. Goals + +1. **Deliver a clean, intuitive remote-control interface** optimized for tablets/phones. +2. **Support all GSPro keyboard shortcuts** via touch-friendly buttons. +3. **Provide low-latency streaming of the GSPro map/mini-map** (\~200ms or less). +4. **Persist user settings/configuration** locally on the GSPro PC between sessions. +5. **Make installation simple** for non-technical users (one installer, auto-start server). +6. **Prepare for future OCR-based automation**, but keep it out of MVP. + +--- + +## 3. Non-Goals + +* Multiplayer sync or cloud-hosted services. (There is zero value in a any cloud related services) +* Mobile native apps (PWA installable from browser is sufficient). +* Full-fledged vision debugging UI for end-users (that stays an advanced/dev option). + +--- + +## 4. Target Audience + +* **Primary:** GSPro simulator owners with a launch monitor, seeking more convenient in-round controls. +* **Secondary:** Enthusiasts who like to customize sim setups (may use advanced OCR features later). + +Users are assumed to be **non-technical golfers**, not developers. Installation and updates must be straightforward. + +--- + +## 5. Personas & Use Cases + +### Persona A: “Weekend Golfer” + +* Wants to control GSPro from a tablet while standing on the mat. +* Needs quick access to Aim, Mulligan, Club Selection, Tee Box controls. +* Doesn’t want to touch a keyboard during play. + +--- + +## 6. Functional Requirements + +### 6.1 Core Features (MVP) + +* **UI Controls** + + * Directional Pad for Aim (Up/Down/Left/Right/Reset). + * Club Indicator + Detection (manual at first, OCR planned later). + * Mulligan button. + * Tee box navigation (previous/next tee). + * Map panel with expand/collapse. (should stream the map from the GSPro UI) + +* **Backend Integration** + + * WebSocket streaming of GSPro map region. + * API endpoints to send keypresses mapped to GSPro shortcuts. + * Config persistence (JSON stored on host PC). + * mDNS discovery (`gsproapp.local`) for easy access. + +* **Performance** + + * Streaming latency under 200ms (720p @ 30fps acceptable). + * Smooth interaction (key commands executed <100ms). + +### 6.2 Advanced / V2 Features + +* **OCR & Region Detection** + + * EasyOCR/Tesseract integration to auto-detect club, map, shot info. + * Auto-configuration of regions (user doesn’t manually define). +* **Custom Monitor Tasks** + + * Background monitoring of regions for changes (e.g., “detect when putting mode enabled”). +* **Marker Detection** + + * Visual template matching for advanced element tracking. +* **Extended Controls** + + * Support for additional GSPro shortcuts (e.g., free look, flyover, lighting). + +--- + +## 7. Technical Architecture + +* **Frontend:** React + TypeScript + + * `DynamicGolfUI` = Main consumer UI (touch-friendly, polished). + * `VisionDashboard` = Developer/advanced-only UI (OCR testing, markers). + +* **Backend:** Python + FastAPI + + * `/api/actions/key` → Trigger GSPro keypresses. + * `/api/vision/ws/stream` → WebSocket for video streaming. + * `/api/vision/ocr`, `/api/vision/markers`, `/api/vision/regions` → Vision endpoints (advanced). + +* **System Design** + + * Runs locally on Windows (FastAPI server). + * UI served at `http://gsproapp.local:5005/ui`. + * Input simulated via `pydirectinput` to control GSPro. + * Screen captured via `mss` + OpenCV. + +--- + +## 8. User Experience + +* **Primary UI (DynamicGolfUI)** + Matches "GSPro App.png" in project root directory: + + * Top: App name/version, Mulligan button. + * Left: Club indicator. + * Center: Aim directional pad. + * Right: Map stream (expandable). + * Bottom: Shot Options + Tee controls. + +* **Advanced UI (VisionDashboard)** + Hidden behind advanced toggle. Used by devs to: + + * Test OCR engines. + * Define/capture regions. + * Add/verify markers. + * Monitor tasks. + +--- + +## 9. Success Metrics + +* MVP launch: + + * Users can control 90% of their round without a keyboard. + * Map streaming latency <200ms. + * 1-click installer on Windows. +* V2: + + * Automatic detection of club + map area with >90% reliability. + * Config persistence across restarts without user intervention. + +--- + +## 10. Milestones + +* **Phase 1 (MVP / Public Beta)** + + * Polish DynamicGolfUI. + * Ensure all GSPro shortcuts are covered. + * Stable WebSocket streaming. + * Windows installer packaging. + +* **Phase 2 (Vision + OCR)** + + * Add OCR-based auto-detection. + * Background monitor tasks. + * VisionDashboard improvements. + +* **Phase 3 (Subscription-ready)** + + * Licensing/subscription integration. + * Polished UX/UI redesign (visual depth, animations, better icons). + * Documentation & onboarding guide. + +--- + +## 11. Risks / Open Questions + +* Will OCR performance be good enough at different resolutions (1080p vs 4K)? +* Will average users struggle with network/firewall setup for accessing the web UI? +* Packaging FastAPI + dependencies into an easy Windows installer (PyInstaller vs. MSI vs. Docker)? + +--- + +## 12. Feature Mapping Matrix + +| **UI Control** | **GSPro Shortcut** | **API Endpoint** | **Notes** | +| ----------------------------------------- | ------------------------------- | ----------------------------------------- | ---------------------------------------------- | +| **Reset** | `A` | `POST /api/actions/key` | Reset | +| **Aim Pad (Up/Down/Left/Right)** | Arrow keys | `POST /api/actions/keydown` + `/keyup` | aim adjust = arrow keys, needs “hold” support | +| **Club Selection (Up/Down)** | `U` (club up), `K` (club down) | `POST /api/actions/key` | Shown as club indicator in UI | +| **Mulligan** | `Ctrl+M` | `POST /api/actions/key` | Toggle mulligan | +| **Shot Options (Normal/Punch/Flop/Chip)** | `'` (apostrophe) + context? | `POST /api/actions/key` | Might need mapping to GSPro UI if not explicit | +| **Tee Box (Left/Right)** | `C` (tee left), `V` (tee right) | `POST /api/actions/key` | Navigates tee position | +| **Map Expand/Collapse** | `S` | `POST /api/actions/key` | Toggles map size | +| **Map Zoom (In/Out)** | `Q` (zoom +), `W` (zoom -) | `POST /api/actions/key` | Could be UI slider later | +| **Map Click (Expanded)** | Mouse click | `POST /api/actions/key` or WS click event | Normalized coordinates mapped to screen | +| **Scorecard** | `T` | `POST /api/actions/key` | Toggle scorecard | +| **Range Finder** | `R` | `POST /api/actions/key` | Launch rangefinder | +| **Heat Map** | `Y` | `POST /api/actions/key` | Toggle heatmap overlay | +| **Putt Toggle** | `U` | `POST /api/actions/key` | Enable/disable putting mode | +| **Pin Indicator** | `P` | `POST /api/actions/key` | Show/hide pin indicator | +| **Flyover** | `O` | `POST /api/actions/key` | Hole preview | +| **Tracer Clear** | `F1` | `POST /api/actions/key` | Clear ball tracer | +| **Aim Point** | `F3` | `POST /api/actions/key` | Toggle aim point view | +| **Free Look** | `F5` | `POST /api/actions/key` | Unlock camera | +| **Console Short/Tall** | `F8` / `F9` | `POST /api/actions/key` | Open GSPro dev consoles | +| **Fullscreen** | `F11` | `POST /api/actions/key` | Toggle fullscreen | +| **Camera Go To Ball** | `5` | `POST /api/actions/key` | Jump to ball cam | +| **Camera Fly To Ball** | `6` | `POST /api/actions/key` | Fly cam to ball | +| **Go To Ball (practice)** | `8` | `POST /api/actions/key` | Only in practice mode | +| **Previous/Next Hole** | `9` / `0` | `POST /api/actions/key` | Only in practice mode | +| **Sound On/Off** | `+` / `-` | `POST /api/actions/key` | Volume control | +| **FPS Display** | `F` | `POST /api/actions/key` | Show/hide FPS | +| **Green Grid** | `G` | `POST /api/actions/key` | Show/hide green putting grid | +| **Lighting** | `L` | `POST /api/actions/key` | Adjust lighting | +| **Shot Camera** | `J` | `POST /api/actions/key` | Switch to shot cam | +| **UI Hide/Show** | `H` | `POST /api/actions/key` | Toggle GSPro UI | +| **3D Grass Toggle** | `Z` | `POST /api/actions/key` | Show/hide grass detail | +| **Switch Handedness** | `N` | `POST /api/actions/key` | Left/right handed player | +| **Shadow Intensity** | `<` / `>` | `POST /api/actions/key` | Adjust shadows | +| **Fast Forward Ball Roll** | Hold key (space?) | `POST /api/actions/keydown` + `/keyup` | Needs “hold” support | \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a055ed4 --- /dev/null +++ b/README.md @@ -0,0 +1,212 @@ +# GSPro Remote + +A companion application for GSPro golf simulator that allows remote control from any device on your network. + +## 🎯 Overview + +GSPro Remote is a web-based remote control application for GSPro golf simulator running on Windows. Control GSPro from your tablet, phone, or another PC without needing a keyboard nearby. + +### Key Features + +- **Touch-Friendly Interface** - Optimized for tablets and phones +- **All GSPro Shortcuts** - Full keyboard shortcut support via touch controls +- **Live Map Streaming** - Real-time streaming of the GSPro mini-map +- **mDNS Discovery** - Access via `gsproapp.local` on your network +- **Persistent Settings** - Configuration saved between sessions +- **Zero Cloud Dependencies** - Everything runs locally on your network + +## 🚀 Quick Start + +### Prerequisites + +- Windows PC running GSPro +- Python 3.11+ +- Node.js 20+ +- GSPro golf simulator + +### Installation + +1. Clone the repository: +```bash +git clone https://github.com/yourusername/gspro-remote.git +cd gspro-remote +``` + +2. Run the development setup: +```powershell +.\scripts\dev.ps1 +``` + +This will: +- Install all backend dependencies +- Install all frontend dependencies +- Start both development servers +- Open the UI at http://localhost:5173 + +### Access Points + +- **Frontend UI**: http://localhost:5173 +- **Backend API**: http://localhost:5005 +- **API Documentation**: http://localhost:5005/api/docs +- **mDNS Access**: http://gsproapp.local:5005 + +## 📁 Project Structure + +``` +gspro-remote/ +├── backend/ # Python FastAPI backend +│ ├── app/ +│ │ ├── api/ # API endpoints +│ │ │ ├── actions.py # Keyboard control +│ │ │ ├── vision.py # Screen streaming +│ │ │ ├── config.py # Configuration +│ │ │ └── system.py # System utilities +│ │ ├── core/ # Core functionality +│ │ │ ├── config.py # Config management +│ │ │ ├── input_ctrl.py # Windows input +│ │ │ ├── screen.py # Screen capture +│ │ │ └── mdns.py # mDNS service +│ │ └── main.py # FastAPI app +│ └── pyproject.toml # Python dependencies +│ +├── frontend/ # React TypeScript frontend +│ ├── src/ +│ │ ├── pages/ # Page components +│ │ ├── components/ # UI components +│ │ ├── api/ # API client +│ │ ├── stores/ # State management +│ │ └── App.tsx # Main app +│ └── package.json # Node dependencies +│ +├── scripts/ # Development scripts +│ └── dev.ps1 # Windows dev script +│ +└── PRD.md # Product requirements +``` + +## 🎮 Features + +### Phase 1 (MVP) - Current +- ✅ Directional pad for aim control +- ✅ Club selection (up/down) +- ✅ Mulligan button +- ✅ Tee box navigation +- ✅ Map panel with streaming +- ✅ All GSPro keyboard shortcuts +- ✅ WebSocket-based streaming +- ✅ Configuration persistence +- ✅ mDNS service discovery + +### Phase 2 (Planned) +- ⏳ OCR-based auto-detection +- ⏳ Visual marker tracking +- ⏳ Background monitoring +- ⏳ Advanced automation + +### Phase 3 (Future) +- ⏳ Enhanced UI/UX +- ⏳ Subscription features +- ⏳ Extended documentation + +## 🛠️ Development + +### Backend Development + +```bash +cd backend +python -m venv .venv +.venv\Scripts\activate +pip install -e ".[dev]" +uvicorn app.main:app --reload --host 0.0.0.0 --port 5005 +``` + +### Frontend Development + +```bash +cd frontend +npm install +npm run dev +``` + +### Running Tests + +Backend: +```bash +cd backend +pytest +``` + +Frontend: +```bash +cd frontend +npm test +``` + +## 📖 API Documentation + +The backend provides a comprehensive REST API with WebSocket support for streaming. + +### Key Endpoints + +- `POST /api/actions/key` - Send keyboard input to GSPro +- `WS /api/vision/ws/stream` - WebSocket for map streaming +- `GET /api/config` - Get current configuration +- `GET /api/system/health` - Health check + +Full API documentation available at http://localhost:5005/api/docs when running. + +## 🔧 Configuration + +Configuration is stored at `%LOCALAPPDATA%\GSPro Remote\config.json` + +### Default Configuration + +```json +{ + "server": { + "host": "0.0.0.0", + "port": 5005, + "mdns_enabled": true + }, + "capture": { + "fps": 30, + "quality": 85, + "resolution": "720p" + }, + "gspro": { + "window_title": "GSPro", + "auto_focus": true + } +} +``` + +## 🐛 Troubleshooting + +### GSPro Window Not Found +- Ensure GSPro is running +- Check window title in configuration +- Run backend as administrator if needed + +### Connection Issues +- Verify both frontend and backend are running +- Check Windows firewall settings +- Ensure devices are on same network + +### Port Already in Use +- Check if another instance is running +- Change port in configuration +- Use `netstat -an | findstr :5005` to check + +## 📝 License + +MIT License - See LICENSE file for details + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## 📞 Support + +For issues and questions: +- Create an issue on GitHub +- Check existing issues for solutions \ No newline at end of file diff --git a/Recommended kickoff plan.md b/Recommended kickoff plan.md new file mode 100644 index 0000000..cd0705c --- /dev/null +++ b/Recommended kickoff plan.md @@ -0,0 +1,191 @@ +# Recommended kickoff plan + +## 0) Toolchain (lock these down) + +* **Python** 3.11 (for wheels availability + perf) +* **Node** 20.19+ and **npm** 10+ +* **UV** for deps +* **Playwright** (later for UI smoke tests) +* **Git** repo from day 1 + +## 1) Make a tiny, opinionated repo scaffold + +Create a new repo (you can copy/paste this tree into your README so the LLM follows it): + +``` +gspro-remote/ + backend/ + app/ + __init__.py + main.py # FastAPI app factory + router mounts + api/ + __init__.py + actions.py # /api/actions/* + vision.py # /api/vision/* (keep but V2-gated) + core/ + config.py # AppConfig + load/save + input_ctrl.py # pydirectinput helpers + screen.py # mss capture utils (non-vision bits) + pkg.json # (optional) for uvicorn dev script via npm - or just use Makefile + pyproject.toml + README.md + frontend/ + src/ + main.tsx + App.tsx + pages/DynamicGolfUI.tsx + components/ + AimPad.tsx + StatBar.tsx + MapPanel.tsx + styles/ + index.html + package.json + vite.config.ts + README.md + scripts/ + dev.ps1 # start both servers on Windows + .editorconfig + .gitignore + README.md +``` + + +## 2) Bootstrap bare bones projects + +### Backend (FastAPI) + +```bash +cd backend +uv venv && uv pip install fastapi uvicorn pydantic-settings pydirectinput pywin32 mss opencv-python pillow zeroconf +# Or with pip: +# python -m venv .venv && .venv\Scripts\activate && pip install fastapi uvicorn pydirectinput pywin32 mss opencv-python pillow zeroconf +``` + +`app/main.py` (minimal) + +```python +from fastapi import FastAPI +from .api.actions import router as actions_router + +def create_app() -> FastAPI: + app = FastAPI(title="GSPro Remote", version="0.1.0") + app.include_router(actions_router, prefix="/api/actions", tags=["actions"]) + return app + +app = create_app() +``` + +`app/api/actions.py` (just enough to prove the vertical slice) + +```python +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from ..core.input_ctrl import press_keys, focus_window + +router = APIRouter() + +class KeyReq(BaseModel): + keys: str + +@router.post("/key") +def post_key(req: KeyReq): + if not focus_window("GSPro"): + raise HTTPException(409, "GSPro window not found/active") + press_keys(req.keys) + return {"ok": True} +``` + +Run: + +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 5005 +``` + +### Frontend (Vite + React + TS) + +```bash +cd ../frontend +npm create vite@latest . -- --template react-ts +npm i +npm run dev +``` + +Add a `.env` in frontend later if you want a configurable API base URL. + +## 3) Migrate your existing code **incrementally** + +You already have solid pieces. Bring them in slice by slice: + +* Move `config.py` → `backend/app/core/config.py` (keep AppConfig + persistence). +* Move `input_ctrl.py` → `backend/app/core/input_ctrl.py` (unchanged). +* Create `backend/app/api/vision.py` and paste **only** the streaming endpoint you’ll use first (WebSocket or SSE). Keep OCR endpoints behind a `VISION_ENABLED` flag for V2. +* Defer `screen_watch.py`, `capture.py`, `streaming.py` until the “MapPanel” slice (below). It’s okay if V1 ships with **no** OCR. + +## 4) Implement one **vertical slice** end-to-end (MVP proof) + +Start with the **Aim Pad + Mulligan** because it touches everything: + +* Frontend: + + * `components/AimPad.tsx`: buttons call `POST /api/actions/key` with `"left"`, `"right"`, `"up"`, `"down"`, and `"a"` (Reset). + * Add Mulligan button calling `"ctrl+m"`. + +* Backend: + + * The `post_key` route already exists. + * Make sure `focus_window("GSPro")` works on your machine. + +* Test on Windows: you should see GSPro react from the tablet/phone. + +Now you have a working remote! + +## 5) Add the **MapPanel** as the second slice + +* Backend: + + * Introduce a simple `/api/vision/ws/stream` that returns a **downscaled JPEG** buffer of a fixed region; reuse your `mss` capture and JPEG base64 helpers. Keep the code minimal. +* Frontend: + + * `components/MapPanel.tsx` opens a WS to `/api/vision/ws/stream`, paints frames onto a ``, supports expand/collapse (no click mapping yet). +* Aim for **720p @ 30fps, \~75–85 JPEG quality**. Measure latency, then optimize resize **before** JPEG encode. + +## 6) Wire the rest of the “Essentials” + +* Tee left/right (`c`/`v`) +* Scorecard (`t`) and Range finder (`r`) as secondary buttons under a kebab menu +* Stat row at bottom (hard-coded 0.0° up/right for now—wire later) + +## 7) Keep OCR as **V2** but leave hooks + +* Add a feature flag `VISION_ENABLED = False` in `app/core/config.py`. +* Keep `vision.py` imported but routes gated: if disabled, return 404 with a friendly message. +* This lets you merge OCR work later without reshaping the app. + +## 8) Dev ergonomics on Windows + +Create `scripts/dev.ps1` to run both servers: + +```powershell +Start-Job { Set-Location backend; uvicorn app.main:app --reload --port 5005 } +Start-Sleep -Seconds 1 +Start-Process powershell -ArgumentList 'cd frontend; npm run dev' +``` + +## 9) Packaging (when MVP is stable) + +* **Backend**: PyInstaller or Nuitka into a single EXE, then wrap with **Inno Setup** to produce an installer that: + + * Installs the EXE + config files to `C:\ProgramData\GSPro Remote\` + * Creates a Start Menu entry and an **auto-start shortcut** for the Windows user + * Opens firewall rule for your port +* **Frontend**: `npm run build` → copy `dist/` into `backend/ui/` and serve using FastAPI `StaticFiles`. (You already had `build-ui.py`; keep that idea.) + +## 10) How to use the LLM effectively (so it helps, not hurts) + +* **Give it the scaffold** and the **PRD** (and the Section-12 matrix). +* Ask for **one file at a time**: “Implement `components/AimPad.tsx` against `/api/actions/key`” with explicit props and return types. +* Paste back **real compiler/server errors** verbatim; ask it to fix **only those**. +* Freeze the public contracts early (API request/response shapes). LLMs drift if the interface is fuzzy. + +--- \ No newline at end of file diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..3cdcb70 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,259 @@ +# GSPro Remote - Setup & Run Instructions + +## 🚀 Quick Setup Guide + +Follow these steps to get GSPro Remote running on your network and accessible from your phone/tablet. + +## Prerequisites + +1. **Windows PC** with GSPro installed +2. **Python 3.11+** installed ([Download Python](https://www.python.org/downloads/)) +3. **Node.js 20+** installed ([Download Node.js](https://nodejs.org/)) +4. **Git** (optional, for cloning the repository) + +## Step 1: Initial Setup + +### Option A: Using PowerShell Script (Recommended) + +1. Open PowerShell as Administrator +2. Navigate to the project directory: + ```powershell + cd "path\to\GSPro Remote" + ``` +3. Run the development script: + ```powershell + .\scripts\dev.ps1 + ``` + +This script will: +- Check all prerequisites +- Install Python dependencies +- Install Node.js dependencies +- Start both backend and frontend servers +- Open browser windows with the application + +### Option B: Manual Setup + +#### Backend Setup: +```bash +cd backend +python -m venv .venv +.venv\Scripts\activate +pip install -e . +``` + +#### Frontend Setup: +```bash +cd frontend +npm install +``` + +## Step 2: Running the Application + +### Start Backend Server: +```bash +cd backend +.venv\Scripts\activate +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 5005 +``` + +The backend will be available at: +- API: `http://localhost:5005` +- API Docs: `http://localhost:5005/api/docs` + +### Start Frontend Server: +```bash +cd frontend +npm run dev -- --host +``` + +The frontend will be available at: +- Local: `http://localhost:5173` +- Network: `http://[YOUR-IP]:5173` + +## Step 3: Access from Your Phone/Tablet + +### Find Your PC's IP Address + +1. Open Command Prompt or PowerShell +2. Run: `ipconfig` +3. Look for your IPv4 Address (e.g., `192.168.1.100`) + +### Connect from Mobile Device + +1. **Ensure your phone/tablet is on the same WiFi network as your PC** + +2. Open a web browser on your mobile device + +3. Navigate to one of these URLs: + - `http://[YOUR-PC-IP]:5173` (e.g., `http://192.168.1.100:5173`) + - `http://gsproapp.local:5005/ui` (if mDNS is working) + +### Install as Mobile App (PWA) + +**On iPhone/iPad:** +1. Open Safari (must be Safari) +2. Navigate to the app URL +3. Tap the Share button +4. Scroll down and tap "Add to Home Screen" +5. Name it "GSPro Remote" and tap Add + +**On Android:** +1. Open Chrome +2. Navigate to the app URL +3. Tap the menu (3 dots) +4. Tap "Add to Home screen" or "Install app" +5. Follow the prompts + +## Step 4: Configure Windows Firewall + +If you can't connect from your mobile device, you may need to allow the app through Windows Firewall: + +1. Open Windows Defender Firewall +2. Click "Allow an app or feature through Windows Defender Firewall" +3. Click "Change settings" then "Allow another app..." +4. Browse and add: + - Python.exe (from your Python installation) + - Node.exe (from your Node.js installation) +5. Check both Private and Public networks +6. Click OK + +### Alternative: Create Firewall Rules via PowerShell (Admin) +```powershell +# Allow backend port +New-NetFirewallRule -DisplayName "GSPro Remote Backend" -Direction Inbound -Protocol TCP -LocalPort 5005 -Action Allow + +# Allow frontend port +New-NetFirewallRule -DisplayName "GSPro Remote Frontend" -Direction Inbound -Protocol TCP -LocalPort 5173 -Action Allow +``` + +## Step 5: Start Using GSPro Remote + +1. **Start GSPro** on your PC +2. **Open GSPro Remote** on your mobile device +3. The app should show "Connected" status +4. You can now control GSPro from your mobile device! + +## 🎮 Using the App + +### Main Controls: +- **Aim Pad**: Hold arrows to adjust aim continuously +- **Center Button**: Press to reset aim +- **Club Selection**: Tap up/down to change clubs +- **Mulligan**: Toggle mulligan mode +- **Tee Box**: Move left/right on the tee +- **Map**: Start streaming to see live mini-map +- **Shot Options**: Select different shot types +- **Quick Actions** (+ button): Access additional controls + +### Gestures: +- **Hold** directional buttons for continuous movement +- **Tap** for single adjustments +- **Expand Map**: Tap expand button for fullscreen map + +## 🔧 Troubleshooting + +### Can't connect from phone +- ✅ Check both devices are on the same WiFi network +- ✅ Check Windows Firewall settings +- ✅ Try using IP address instead of hostname +- ✅ Disable VPN if active +- ✅ Make sure backend shows "Server running on 0.0.0.0:5005" + +### GSPro not responding to commands +- ✅ Ensure GSPro is running and focused +- ✅ Check GSPro window title matches config (default: "GSPro") +- ✅ Try running backend as Administrator +- ✅ Check backend console for errors + +### Map streaming not working +- ✅ Click "Start Streaming" button in map panel +- ✅ Check backend console for WebSocket connection +- ✅ Verify GSPro is on the main game screen +- ✅ Adjust map region in settings if needed + +### Poor performance on mobile +- ✅ Reduce stream quality in settings +- ✅ Lower FPS to 15-20 +- ✅ Use 5GHz WiFi if available +- ✅ Move closer to WiFi router + +## 📱 Mobile-Specific Tips + +### For Best Experience: +1. **Lock screen orientation** to prevent rotation while playing +2. **Enable "Add to Home Screen"** for full-screen experience +3. **Disable browser gestures** if they interfere +4. **Use landscape mode** on tablets for optimal layout +5. **Keep screen awake** during gameplay + +### Battery Optimization: +- Lower stream quality to "Low" or "Medium" +- Reduce FPS to 15-20 +- Disable haptic feedback if not needed +- Dim screen brightness when possible + +## 🔒 Security Notes + +- The app only works on your local network +- No data is sent to external servers +- All communication is between your PC and mobile device +- Consider using a dedicated WiFi network for gaming + +## 🆘 Getting Help + +### Check Status: +1. Backend health: `http://localhost:5005/api/system/health` +2. API docs: `http://localhost:5005/api/docs` +3. System info: `http://localhost:5005/api/system/info` +4. Diagnostics: `http://localhost:5005/api/system/diagnostics` + +### View Logs: +- Backend logs appear in the terminal where you started the server +- Frontend logs appear in the browser console (F12) + +### Common Commands: +```bash +# Check if ports are in use +netstat -an | findstr :5005 +netstat -an | findstr :5173 + +# Check Python version +python --version + +# Check Node version +node --version + +# Check network interfaces +ipconfig /all + +# Test backend directly +curl http://localhost:5005/api/system/health +``` + +## 🎯 Production Deployment + +For permanent installation: + +1. **Build the frontend**: + ```bash + cd frontend + npm run build + ``` + +2. **Configure backend to serve UI**: + - Frontend will be built to `backend/ui` + - Access complete app at `http://gsproapp.local:5005` + +3. **Create Windows Service** (optional): + - Use NSSM or similar to run backend as service + - Auto-starts with Windows + - Runs in background + +4. **Configure auto-start**: + - Add to Windows startup folder + - Or use Task Scheduler + +--- + +**Enjoy controlling GSPro from anywhere in your simulator room!** 🏌️‍♂️ \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..d02b57c --- /dev/null +++ b/backend/README.md @@ -0,0 +1,167 @@ +# GSPro Remote Backend + +FastAPI-based backend service for GSPro Remote, providing keyboard control and screen streaming capabilities for GSPro golf simulator. + +## Features + +- **Keyboard Control API**: Send keyboard shortcuts to GSPro +- **Screen Streaming**: WebSocket-based map region streaming +- **Configuration Management**: Persistent settings storage +- **mDNS Discovery**: Auto-discoverable at `gsproapp.local` +- **Windows Integration**: Native Windows input simulation + +## Requirements + +- Python 3.11+ +- Windows OS (for GSPro integration) +- GSPro running on the same machine + +## Installation + +### Using pip + +```bash +python -m venv .venv +.venv\Scripts\activate +pip install -e . +``` + +### Using UV (recommended) + +```bash +uv venv +uv pip install -e . +``` + +## Development Setup + +1. Install development dependencies: +```bash +pip install -e ".[dev]" +``` + +2. Run the development server: +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 5005 +``` + +## API Structure + +``` +/api/actions/ + POST /key - Send single key press + POST /keydown - Hold key down + POST /keyup - Release key + POST /combo - Send key combination + +/api/config/ + GET / - Get current configuration + PUT / - Update configuration + POST /save - Persist to disk + +/api/vision/ + WS /ws/stream - WebSocket map streaming + GET /regions - Get defined screen regions + POST /capture - Capture screen region + +/api/system/ + GET /health - Health check + GET /info - System information +``` + +## Configuration + +Configuration is stored in `%LOCALAPPDATA%\GSPro Remote\config.json` + +Default configuration: +```json +{ + "server": { + "host": "0.0.0.0", + "port": 5005, + "mdns_enabled": true + }, + "capture": { + "fps": 30, + "quality": 85, + "resolution": "720p" + }, + "gspro": { + "window_title": "GSPro", + "auto_focus": true + } +} +``` + +## Project Structure + +``` +backend/ + app/ + __init__.py + main.py # FastAPI application + api/ + actions.py # Keyboard control endpoints + config.py # Configuration endpoints + vision.py # Screen capture/streaming + system.py # System utilities + core/ + config.py # Configuration management + input_ctrl.py # Windows input simulation + screen.py # Screen capture utilities + mdns.py # mDNS service registration + models/ + requests.py # Pydantic request models + responses.py # Pydantic response models + tests/ + test_*.py # Unit tests + pyproject.toml # Project dependencies +``` + +## Testing + +Run tests with pytest: +```bash +pytest +``` + +With coverage: +```bash +pytest --cov=app --cov-report=html +``` + +## Building for Distribution + +Build standalone executable: +```bash +pip install ".[build]" +python -m PyInstaller --onefile --name gspro-remote app/main.py +``` + +## Environment Variables + +- `GSPRO_REMOTE_PORT`: Override default port (5005) +- `GSPRO_REMOTE_HOST`: Override default host (0.0.0.0) +- `GSPRO_REMOTE_CONFIG_PATH`: Override config location +- `GSPRO_REMOTE_DEBUG`: Enable debug logging + +## Troubleshooting + +### GSPro window not found +- Ensure GSPro is running +- Check window title matches configuration +- Run as administrator if permission issues + +### Port already in use +- Check if another instance is running +- Change port in configuration +- Use `netstat -an | findstr :5005` to check + +### mDNS not working +- Check Windows firewall settings +- Ensure Bonjour service is running +- Try accessing directly via IP instead + +## License + +MIT License - See parent LICENSE file for details \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..4859a84 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,13 @@ +""" +GSPro Remote Backend Application + +A FastAPI-based backend service for controlling GSPro golf simulator +via keyboard shortcuts and providing screen streaming capabilities. +""" + +__version__ = "0.1.0" +__author__ = "GSPro Remote Team" + +from .main import app + +__all__ = ["app", "__version__"] diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..673bb72 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,7 @@ +""" +API module for GSPro Remote backend. +""" + +from . import actions, config, system, vision + +__all__ = ["actions", "config", "system", "vision"] diff --git a/backend/app/api/actions.py b/backend/app/api/actions.py new file mode 100644 index 0000000..6033b46 --- /dev/null +++ b/backend/app/api/actions.py @@ -0,0 +1,232 @@ +""" +Actions API for sending keyboard inputs to GSPro. +""" + +import asyncio +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, BackgroundTasks +from pydantic import BaseModel, Field + +from ..core.input_ctrl import press_key, press_keys, key_down, key_up, focus_window, is_gspro_running +from ..core.config import get_config + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class KeyPressRequest(BaseModel): + """Request model for single key press.""" + + key: str = Field(..., description="Key or key combination to press (e.g., 'a', 'ctrl+m')") + delay: Optional[float] = Field(0.0, description="Delay in seconds before pressing the key") + + +class KeyHoldRequest(BaseModel): + """Request model for key hold operations.""" + + key: str = Field(..., description="Key to hold or release") + duration: Optional[float] = Field(None, description="Duration to hold the key (seconds)") + + +class KeySequenceRequest(BaseModel): + """Request model for key sequence.""" + + keys: list[str] = Field(..., description="List of keys to press in sequence") + interval: float = Field(0.1, description="Interval between key presses (seconds)") + + +class ActionResponse(BaseModel): + """Response model for action endpoints.""" + + success: bool + message: str + key: Optional[str] = None + + +@router.post("/key", response_model=ActionResponse) +async def send_key(request: KeyPressRequest): + """ + Send a single key press or key combination to GSPro. + + Supports: + - Single keys: 'a', 'space', 'enter' + - Combinations: 'ctrl+m', 'shift+tab' + - Function keys: 'f1', 'f11' + - Arrow keys: 'up', 'down', 'left', 'right' + """ + config = get_config() + + # Check if GSPro is running + if not is_gspro_running(): + raise HTTPException(status_code=409, detail="GSPro is not running or window not found") + + # Focus GSPro window if auto-focus is enabled + if config.gspro.auto_focus: + if not focus_window(config.gspro.window_title): + logger.warning(f"Could not focus window: {config.gspro.window_title}") + + try: + # Apply delay if specified + if request.delay > 0: + await asyncio.sleep(request.delay) + + # Send the key press + if "+" in request.key: + # Handle key combination + press_keys(request.key) + else: + # Handle single key + press_key(request.key) + + logger.info(f"Sent key press: {request.key}") + return ActionResponse(success=True, message=f"Key '{request.key}' pressed successfully", key=request.key) + except Exception as e: + logger.error(f"Failed to send key press: {e}") + raise HTTPException(status_code=500, detail=f"Failed to send key press: {str(e)}") + + +@router.post("/keydown", response_model=ActionResponse) +async def send_key_down(request: KeyHoldRequest, background_tasks: BackgroundTasks): + """ + Press and hold a key down. + + If duration is specified, the key will be automatically released after that time. + Otherwise, you must call /keyup to release it. + """ + config = get_config() + + if not is_gspro_running(): + raise HTTPException(status_code=409, detail="GSPro is not running or window not found") + + if config.gspro.auto_focus: + focus_window(config.gspro.window_title) + + try: + key_down(request.key) + logger.info(f"Key down: {request.key}") + + # If duration is specified, schedule key release + if request.duration: + + async def release_key(): + await asyncio.sleep(request.duration) + key_up(request.key) + logger.info(f"Key released after {request.duration}s: {request.key}") + + background_tasks.add_task(release_key) + + return ActionResponse( + success=True, message=f"Key '{request.key}' held for {request.duration}s", key=request.key + ) + else: + return ActionResponse( + success=True, message=f"Key '{request.key}' pressed down (call /keyup to release)", key=request.key + ) + except Exception as e: + logger.error(f"Failed to hold key down: {e}") + raise HTTPException(status_code=500, detail=f"Failed to hold key down: {str(e)}") + + +@router.post("/keyup", response_model=ActionResponse) +async def send_key_up(request: KeyHoldRequest): + """ + Release a held key. + """ + config = get_config() + + if not is_gspro_running(): + raise HTTPException(status_code=409, detail="GSPro is not running or window not found") + + if config.gspro.auto_focus: + focus_window(config.gspro.window_title) + + try: + key_up(request.key) + logger.info(f"Key up: {request.key}") + + return ActionResponse(success=True, message=f"Key '{request.key}' released", key=request.key) + except Exception as e: + logger.error(f"Failed to release key: {e}") + raise HTTPException(status_code=500, detail=f"Failed to release key: {str(e)}") + + +@router.post("/sequence", response_model=ActionResponse) +async def send_key_sequence(request: KeySequenceRequest): + """ + Send a sequence of key presses with specified interval between them. + """ + config = get_config() + + if not is_gspro_running(): + raise HTTPException(status_code=409, detail="GSPro is not running or window not found") + + if config.gspro.auto_focus: + focus_window(config.gspro.window_title) + + try: + for i, key in enumerate(request.keys): + if "+" in key: + press_keys(key) + else: + press_key(key) + + # Add interval between keys (except after last one) + if i < len(request.keys) - 1: + await asyncio.sleep(request.interval) + + logger.info(f"Sent key sequence: {request.keys}") + return ActionResponse( + success=True, message=f"Sent {len(request.keys)} key presses", key=", ".join(request.keys) + ) + except Exception as e: + logger.error(f"Failed to send key sequence: {e}") + raise HTTPException(status_code=500, detail=f"Failed to send key sequence: {str(e)}") + + +@router.get("/shortcuts") +async def get_shortcuts(): + """ + Get a list of all available GSPro keyboard shortcuts. + """ + shortcuts = { + "aim": {"up": "up", "down": "down", "left": "left", "right": "right", "reset": "a"}, + "club": {"up": "u", "down": "k"}, + "shot": {"mulligan": "ctrl+m", "options": "'", "putt_toggle": "u"}, + "tee": {"left": "c", "right": "v"}, + "view": { + "map_toggle": "s", + "map_zoom_in": "q", + "map_zoom_out": "w", + "scorecard": "t", + "range_finder": "r", + "heat_map": "y", + "pin_indicator": "p", + "flyover": "o", + "free_look": "f5", + "aim_point": "f3", + "green_grid": "g", + "ui_toggle": "h", + }, + "camera": {"go_to_ball": "5", "fly_to_ball": "6", "shot_camera": "j"}, + "practice": {"go_to_ball": "8", "previous_hole": "9", "next_hole": "0"}, + "system": { + "fullscreen": "f11", + "fps_display": "f", + "console_short": "f8", + "console_tall": "f9", + "tracer_clear": "f1", + }, + "settings": { + "sound_on": "+", + "sound_off": "-", + "lighting": "l", + "3d_grass": "z", + "switch_hand": "n", + "shadow_increase": ">", + "shadow_decrease": "<", + }, + } + + return {"shortcuts": shortcuts, "total": sum(len(category) for category in shortcuts.values())} diff --git a/backend/app/api/config.py b/backend/app/api/config.py new file mode 100644 index 0000000..7ac1997 --- /dev/null +++ b/backend/app/api/config.py @@ -0,0 +1,348 @@ +""" +Configuration API for managing GSPro Remote settings. +""" + +import logging +from typing import Any, Dict, Optional + +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ..core.config import get_config, reset_config + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class ConfigUpdateRequest(BaseModel): + """Request model for configuration updates.""" + + server: Optional[Dict[str, Any]] = Field(None, description="Server configuration") + capture: Optional[Dict[str, Any]] = Field(None, description="Capture configuration") + gspro: Optional[Dict[str, Any]] = Field(None, description="GSPro configuration") + vision: Optional[Dict[str, Any]] = Field(None, description="Vision configuration") + debug: Optional[bool] = Field(None, description="Debug mode") + + +class ConfigResponse(BaseModel): + """Response model for configuration endpoints.""" + + success: bool + message: str + config: Optional[Dict[str, Any]] = None + + +class CaptureConfigUpdate(BaseModel): + """Request model for capture configuration updates.""" + + fps: Optional[int] = Field(None, ge=1, le=60) + quality: Optional[int] = Field(None, ge=1, le=100) + resolution: Optional[str] = None + region_x: Optional[int] = Field(None, ge=0) + region_y: Optional[int] = Field(None, ge=0) + region_width: Optional[int] = Field(None, gt=0) + region_height: Optional[int] = Field(None, gt=0) + + +class ServerConfigUpdate(BaseModel): + """Request model for server configuration updates.""" + + host: Optional[str] = None + port: Optional[int] = None + mdns_enabled: Optional[bool] = None + + +class GSProConfigUpdate(BaseModel): + """Request model for GSPro configuration updates.""" + + window_title: Optional[str] = None + auto_focus: Optional[bool] = None + key_delay: Optional[float] = Field(None, ge=0, le=1) + + +class VisionConfigUpdate(BaseModel): + """Request model for vision configuration updates.""" + + enabled: Optional[bool] = None + ocr_engine: Optional[str] = None + confidence_threshold: Optional[float] = Field(None, ge=0, le=1) + + +@router.get("/", response_model=Dict[str, Any]) +async def get_configuration(): + """ + Get the current application configuration. + + Returns all configuration sections including server, capture, gspro, and vision settings. + """ + config = get_config() + return config.to_dict() + + +@router.put("/", response_model=ConfigResponse) +async def update_configuration(request: ConfigUpdateRequest): + """ + Update application configuration. + + Only provided fields will be updated. Nested configuration objects are merged. + """ + config = get_config() + + try: + update_dict = request.model_dump(exclude_none=True) + + if not update_dict: + return ConfigResponse(success=False, message="No configuration changes provided") + + # Update configuration + config.update(**update_dict) + + logger.info(f"Configuration updated: {list(update_dict.keys())}") + + return ConfigResponse(success=True, message="Configuration updated successfully", config=config.to_dict()) + + except Exception as e: + logger.error(f"Failed to update configuration: {e}") + raise HTTPException(status_code=500, detail=f"Failed to update configuration: {str(e)}") + + +@router.post("/save", response_model=ConfigResponse) +async def save_configuration(): + """ + Save the current configuration to disk. + + This ensures changes persist across application restarts. + """ + config = get_config() + + try: + config.save() + + return ConfigResponse( + success=True, message=f"Configuration saved to {config.config_path}", config=config.to_dict() + ) + + except Exception as e: + logger.error(f"Failed to save configuration: {e}") + raise HTTPException(status_code=500, detail=f"Failed to save configuration: {str(e)}") + + +@router.post("/reset", response_model=ConfigResponse) +async def reset_configuration(): + """ + Reset configuration to default values. + + This will overwrite any custom settings with the application defaults. + """ + config = get_config() + + try: + config.reset() + + logger.info("Configuration reset to defaults") + + return ConfigResponse(success=True, message="Configuration reset to defaults", config=config.to_dict()) + + except Exception as e: + logger.error(f"Failed to reset configuration: {e}") + raise HTTPException(status_code=500, detail=f"Failed to reset configuration: {str(e)}") + + +@router.get("/server") +async def get_server_config(): + """Get server-specific configuration.""" + config = get_config() + return config.server.model_dump() + + +@router.put("/server") +async def update_server_config(request: ServerConfigUpdate): + """ + Update server configuration. + + Note: Changing host or port requires application restart to take effect. + """ + config = get_config() + + updates = request.model_dump(exclude_none=True) + + if not updates: + return {"success": False, "message": "No updates provided"} + + try: + config.update(server=updates) + + return { + "success": True, + "message": "Server configuration updated (restart required for host/port changes)", + "config": config.server.model_dump(), + } + + except Exception as e: + logger.error(f"Failed to update server config: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/capture") +async def get_capture_config(): + """Get capture/streaming configuration.""" + config = get_config() + return config.capture.model_dump() + + +@router.put("/capture") +async def update_capture_config(request: CaptureConfigUpdate): + """Update capture/streaming configuration.""" + config = get_config() + + updates = request.model_dump(exclude_none=True) + + if not updates: + return {"success": False, "message": "No updates provided"} + + try: + config.update(capture=updates) + + return {"success": True, "message": "Capture configuration updated", "config": config.capture.model_dump()} + + except Exception as e: + logger.error(f"Failed to update capture config: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/gspro") +async def get_gspro_config(): + """Get GSPro-specific configuration.""" + config = get_config() + return config.gspro.model_dump() + + +@router.put("/gspro") +async def update_gspro_config(request: GSProConfigUpdate): + """Update GSPro-specific configuration.""" + config = get_config() + + updates = request.model_dump(exclude_none=True) + + if not updates: + return {"success": False, "message": "No updates provided"} + + try: + config.update(gspro=updates) + + return {"success": True, "message": "GSPro configuration updated", "config": config.gspro.model_dump()} + + except Exception as e: + logger.error(f"Failed to update GSPro config: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/vision") +async def get_vision_config(): + """Get vision/OCR configuration (V2 features).""" + config = get_config() + return {**config.vision.model_dump(), "note": "Vision features are planned for V2"} + + +@router.put("/vision") +async def update_vision_config(request: VisionConfigUpdate): + """ + Update vision/OCR configuration. + + Note: These features are planned for V2 and not yet implemented. + """ + config = get_config() + + updates = request.model_dump(exclude_none=True) + + if not updates: + return {"success": False, "message": "No updates provided"} + + # Validate OCR engine if provided + if "ocr_engine" in updates and updates["ocr_engine"] not in ["easyocr", "tesseract"]: + raise HTTPException(status_code=400, detail="Invalid OCR engine. Must be 'easyocr' or 'tesseract'") + + try: + config.update(vision=updates) + + return { + "success": True, + "message": "Vision configuration updated (V2 features)", + "config": config.vision.model_dump(), + } + + except Exception as e: + logger.error(f"Failed to update vision config: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/reload", response_model=ConfigResponse) +async def reload_configuration(): + """ + Reload configuration from disk. + + This discards any unsaved changes and reloads from the configuration file. + """ + try: + # Reset the global config instance + reset_config() + + # Get new config (will load from disk) + config = get_config() + + logger.info("Configuration reloaded from disk") + + return ConfigResponse( + success=True, message=f"Configuration reloaded from {config.config_path}", config=config.to_dict() + ) + + except Exception as e: + logger.error(f"Failed to reload configuration: {e}") + raise HTTPException(status_code=500, detail=f"Failed to reload configuration: {str(e)}") + + +@router.get("/path") +async def get_config_path(): + """Get the configuration file path.""" + config = get_config() + return {"path": str(config.config_path), "exists": config.config_path.exists() if config.config_path else False} + + +@router.get("/validate") +async def validate_configuration(): + """ + Validate the current configuration. + + Checks for common issues and required settings. + """ + config = get_config() + + issues = [] + warnings = [] + + # Check server configuration + if config.server.port < 1024: + warnings.append("Server port is below 1024, may require administrator privileges") + + # Check capture configuration + if config.capture.fps > 30: + warnings.append("High FPS may impact performance") + + if config.capture.quality < 50: + warnings.append("Low JPEG quality may result in poor image clarity") + + # Check GSPro configuration + if not config.gspro.window_title: + issues.append("GSPro window title is not set") + + # Check vision configuration + if config.vision.enabled: + warnings.append("Vision features are enabled but not yet implemented (V2)") + + return { + "valid": len(issues) == 0, + "issues": issues, + "warnings": warnings, + "summary": f"{len(issues)} issues, {len(warnings)} warnings", + } diff --git a/backend/app/api/system.py b/backend/app/api/system.py new file mode 100644 index 0000000..7c8fa9d --- /dev/null +++ b/backend/app/api/system.py @@ -0,0 +1,409 @@ +""" +System API for health checks, system information, and diagnostics. +""" + +import logging +import platform +import psutil +import os +from typing import Dict, Any, Optional +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException +from fastapi.responses import JSONResponse + +from ..core.config import get_config +from ..core.input_ctrl import is_gspro_running, get_gspro_process_info + +logger = logging.getLogger(__name__) +router = APIRouter() + +# Track application start time +APP_START_TIME = datetime.now() + + +@router.get("/health") +async def health_check(): + """ + Health check endpoint for monitoring. + + Returns basic health status and service availability. + """ + config = get_config() + + # Check GSPro status + gspro_running = is_gspro_running(config.gspro.window_title) + + # Calculate uptime + uptime = datetime.now() - APP_START_TIME + + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "uptime_seconds": int(uptime.total_seconds()), + "services": { + "api": "running", + "gspro": "connected" if gspro_running else "disconnected", + "mdns": "enabled" if config.server.mdns_enabled else "disabled", + }, + } + + +@router.get("/info") +async def system_info(): + """ + Get detailed system information. + + Returns information about the host system, Python environment, and application. + """ + config = get_config() + + # Get system information + system_info = { + "platform": { + "system": platform.system(), + "release": platform.release(), + "version": platform.version(), + "machine": platform.machine(), + "processor": platform.processor(), + "python_version": platform.python_version(), + }, + "hardware": { + "cpu_count": psutil.cpu_count(), + "cpu_percent": psutil.cpu_percent(interval=1), + "memory": { + "total": psutil.virtual_memory().total, + "available": psutil.virtual_memory().available, + "percent": psutil.virtual_memory().percent, + "used": psutil.virtual_memory().used, + }, + "disk": { + "total": psutil.disk_usage("/").total, + "used": psutil.disk_usage("/").used, + "free": psutil.disk_usage("/").free, + "percent": psutil.disk_usage("/").percent, + }, + }, + "network": { + "hostname": platform.node(), + "interfaces": _get_network_interfaces(), + }, + "application": { + "version": "0.1.0", + "config_path": str(config.config_path), + "debug_mode": config.debug, + "uptime": str(datetime.now() - APP_START_TIME), + "server": { + "host": config.server.host, + "port": config.server.port, + "url": f"http://{config.server.host}:{config.server.port}", + "mdns_url": f"http://gsproapp.local:{config.server.port}" if config.server.mdns_enabled else None, + }, + }, + } + + return system_info + + +@router.get("/gspro/status") +async def gspro_status(): + """ + Get GSPro application status. + + Returns information about the GSPro process if it's running. + """ + config = get_config() + + is_running = is_gspro_running(config.gspro.window_title) + process_info = get_gspro_process_info() if is_running else None + + return { + "running": is_running, + "window_title": config.gspro.window_title, + "process": process_info, + "auto_focus": config.gspro.auto_focus, + "key_delay": config.gspro.key_delay, + } + + +@router.get("/gspro/find") +async def find_gspro_window(): + """ + Search for GSPro windows. + + Helps users identify the correct window title for configuration. + """ + try: + import win32gui + + def enum_window_callback(hwnd, windows): + if win32gui.IsWindowVisible(hwnd) and win32gui.IsWindowEnabled(hwnd): + window_text = win32gui.GetWindowText(hwnd) + if window_text and len(window_text) > 0: + # Look for windows that might be GSPro + if any(keyword in window_text.lower() for keyword in ["gspro", "golf", "simulator"]): + windows.append( + {"title": window_text, "hwnd": hwnd, "suggested": "gspro" in window_text.lower()} + ) + return True + + windows = [] + win32gui.EnumWindows(enum_window_callback, windows) + + return { + "found": len(windows) > 0, + "windows": windows, + "message": "Found potential GSPro windows" if windows else "No GSPro windows found", + } + + except Exception as e: + logger.error(f"Failed to search for GSPro windows: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/metrics") +async def get_metrics(): + """ + Get application metrics and performance statistics. + """ + config = get_config() + + # Get current resource usage + process = psutil.Process() + + metrics = { + "timestamp": datetime.now().isoformat(), + "uptime_seconds": int((datetime.now() - APP_START_TIME).total_seconds()), + "resources": { + "cpu_percent": process.cpu_percent(), + "memory_mb": process.memory_info().rss / 1024 / 1024, + "memory_percent": process.memory_percent(), + "threads": process.num_threads(), + "open_files": len(process.open_files()) if hasattr(process, "open_files") else 0, + "connections": len(process.connections()) if hasattr(process, "connections") else 0, + }, + "system": { + "cpu_percent": psutil.cpu_percent(interval=0.5), + "memory_percent": psutil.virtual_memory().percent, + "disk_io": psutil.disk_io_counters()._asdict() if psutil.disk_io_counters() else {}, + "network_io": psutil.net_io_counters()._asdict() if psutil.net_io_counters() else {}, + }, + } + + return metrics + + +@router.get("/logs") +async def get_logs(lines: int = 100): + """ + Get recent application logs. + + Args: + lines: Number of log lines to return (max 1000) + """ + # This is a placeholder - in production, you'd read from actual log files + return { + "message": "Log retrieval not yet implemented", + "lines_requested": min(lines, 1000), + "log_level": logging.getLevelName(logger.getEffectiveLevel()), + } + + +@router.post("/restart") +async def restart_application(): + """ + Restart the application. + + Note: This endpoint requires proper process management setup. + """ + # This would typically signal the process manager to restart + # For now, just return a message + return { + "success": False, + "message": "Application restart requires process manager setup. Please restart manually.", + } + + +@router.get("/dependencies") +async def check_dependencies(): + """ + Check if all required dependencies are installed and accessible. + """ + dependencies = { + "required": {}, + "optional": {}, + } + + # Check required dependencies + required_modules = [ + "fastapi", + "uvicorn", + "pydantic", + "pydirectinput", + "mss", + "PIL", + "cv2", + "numpy", + "win32gui", + "psutil", + "zeroconf", + ] + + for module_name in required_modules: + try: + module = __import__(module_name) + version = getattr(module, "__version__", "unknown") + dependencies["required"][module_name] = {"installed": True, "version": version} + except ImportError: + dependencies["required"][module_name] = {"installed": False, "version": None} + + # Check optional dependencies (V2 features) + optional_modules = [ + "easyocr", + "pytesseract", + ] + + for module_name in optional_modules: + try: + module = __import__(module_name) + version = getattr(module, "__version__", "unknown") + dependencies["optional"][module_name] = {"installed": True, "version": version} + except ImportError: + dependencies["optional"][module_name] = {"installed": False, "version": None} + + # Check if all required dependencies are installed + all_installed = all(dep["installed"] for dep in dependencies["required"].values()) + + return { + "all_required_installed": all_installed, + "dependencies": dependencies, + "message": "All required dependencies installed" if all_installed else "Some required dependencies are missing", + } + + +@router.get("/diagnostics") +async def run_diagnostics(): + """ + Run comprehensive system diagnostics. + + Checks various aspects of the system and application configuration. + """ + config = get_config() + diagnostics = {"timestamp": datetime.now().isoformat(), "checks": []} + + # Check 1: GSPro connectivity + gspro_running = is_gspro_running(config.gspro.window_title) + diagnostics["checks"].append( + { + "name": "GSPro Connectivity", + "status": "pass" if gspro_running else "fail", + "message": "GSPro is running and accessible" if gspro_running else "GSPro window not found", + } + ) + + # Check 2: Network accessibility + try: + import socket + + socket.create_connection(("8.8.8.8", 53), timeout=3) + network_ok = True + network_msg = "Network connection is working" + except: + network_ok = False + network_msg = "No internet connection detected" + + diagnostics["checks"].append( + { + "name": "Network Connectivity", + "status": "pass" if network_ok else "warning", + "message": network_msg, + } + ) + + # Check 3: Configuration validity + config_valid = config.config_path and config.config_path.exists() + diagnostics["checks"].append( + { + "name": "Configuration File", + "status": "pass" if config_valid else "warning", + "message": f"Configuration file exists at {config.config_path}" + if config_valid + else "Configuration file not found", + } + ) + + # Check 4: Available disk space + disk_usage = psutil.disk_usage("/") + disk_ok = disk_usage.percent < 90 + diagnostics["checks"].append( + { + "name": "Disk Space", + "status": "pass" if disk_ok else "warning", + "message": f"Disk usage at {disk_usage.percent:.1f}%" + + (" - Consider freeing space" if not disk_ok else ""), + } + ) + + # Check 5: Memory availability + memory = psutil.virtual_memory() + memory_ok = memory.percent < 90 + diagnostics["checks"].append( + { + "name": "Memory Availability", + "status": "pass" if memory_ok else "warning", + "message": f"Memory usage at {memory.percent:.1f}%" + + (" - High memory usage detected" if not memory_ok else ""), + } + ) + + # Check 6: Python version + import sys + + python_ok = sys.version_info >= (3, 11) + diagnostics["checks"].append( + { + "name": "Python Version", + "status": "pass" if python_ok else "warning", + "message": f"Python {platform.python_version()}" + + (" - Consider upgrading to 3.11+" if not python_ok else ""), + } + ) + + # Calculate overall status + statuses = [check["status"] for check in diagnostics["checks"]] + if "fail" in statuses: + overall = "fail" + elif "warning" in statuses: + overall = "warning" + else: + overall = "pass" + + diagnostics["overall_status"] = overall + diagnostics["summary"] = { + "passed": sum(1 for s in statuses if s == "pass"), + "warnings": sum(1 for s in statuses if s == "warning"), + "failures": sum(1 for s in statuses if s == "fail"), + } + + return diagnostics + + +def _get_network_interfaces(): + """Helper function to get network interface information.""" + interfaces = [] + + try: + for interface, addrs in psutil.net_if_addrs().items(): + for addr in addrs: + if addr.family == socket.AF_INET: # IPv4 + interfaces.append( + { + "name": interface, + "address": addr.address, + "netmask": addr.netmask, + "broadcast": addr.broadcast, + } + ) + except Exception as e: + logger.warning(f"Could not get network interfaces: {e}") + + return interfaces diff --git a/backend/app/api/vision.py b/backend/app/api/vision.py new file mode 100644 index 0000000..e4b7929 --- /dev/null +++ b/backend/app/api/vision.py @@ -0,0 +1,345 @@ +""" +Vision API for screen capture and streaming. +Currently implements WebSocket streaming for the map panel. +OCR and advanced vision features are gated behind configuration flags for V2. +""" + +import asyncio +import json +import logging +from typing import Optional, Dict, Any +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, Depends +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from ..core.config import get_config +from ..core.screen import get_screen_capture, capture_region + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class StreamConfig(BaseModel): + """Configuration for video streaming.""" + + fps: int = Field(30, ge=1, le=60, description="Frames per second") + quality: int = Field(85, ge=1, le=100, description="JPEG quality") + resolution: str = Field("720p", description="Stream resolution preset") + region_x: int = Field(0, ge=0, description="Region X coordinate") + region_y: int = Field(0, ge=0, description="Region Y coordinate") + region_width: int = Field(640, ge=1, description="Region width") + region_height: int = Field(480, ge=1, description="Region height") + + +class RegionDefinition(BaseModel): + """Definition of a screen region for capture.""" + + name: str = Field(..., description="Region name") + x: int = Field(..., ge=0, description="X coordinate") + y: int = Field(..., ge=0, description="Y coordinate") + width: int = Field(..., gt=0, description="Width") + height: int = Field(..., gt=0, description="Height") + description: Optional[str] = Field(None, description="Region description") + + +class CaptureRequest(BaseModel): + """Request to capture a screen region.""" + + x: int = Field(0, ge=0, description="X coordinate") + y: int = Field(0, ge=0, description="Y coordinate") + width: int = Field(640, gt=0, description="Width") + height: int = Field(480, gt=0, description="Height") + format: str = Field("base64", description="Output format (base64 or raw)") + quality: int = Field(85, ge=1, le=100, description="JPEG quality") + + +class StreamManager: + """Manages WebSocket streaming sessions.""" + + def __init__(self): + self.active_streams: Dict[str, WebSocket] = {} + self.stream_configs: Dict[str, StreamConfig] = {} + + async def add_stream(self, client_id: str, websocket: WebSocket, config: StreamConfig): + """Add a new streaming session.""" + await websocket.accept() + self.active_streams[client_id] = websocket + self.stream_configs[client_id] = config + logger.info(f"Stream started for client {client_id}") + + async def remove_stream(self, client_id: str): + """Remove a streaming session.""" + if client_id in self.active_streams: + del self.active_streams[client_id] + del self.stream_configs[client_id] + logger.info(f"Stream stopped for client {client_id}") + + async def stream_frame(self, client_id: str, frame_data: str): + """Send a frame to a specific client.""" + if client_id in self.active_streams: + websocket = self.active_streams[client_id] + try: + await websocket.send_text(frame_data) + except Exception as e: + logger.error(f"Failed to send frame to client {client_id}: {e}") + await self.remove_stream(client_id) + + +# Global stream manager +stream_manager = StreamManager() + + +@router.websocket("/ws/stream") +async def stream_video(websocket: WebSocket): + """ + WebSocket endpoint for streaming video of a screen region. + + The client should send a JSON message with stream configuration: + { + "action": "start", + "config": { + "fps": 30, + "quality": 85, + "resolution": "720p", + "region_x": 0, + "region_y": 0, + "region_width": 640, + "region_height": 480 + } + } + """ + config = get_config() + client_id = id(websocket) + + try: + # Accept the WebSocket connection + await websocket.accept() + logger.info(f"WebSocket connected: client {client_id}") + + # Wait for initial configuration message + data = await websocket.receive_text() + message = json.loads(data) + + if message.get("action") != "start": + await websocket.send_json({"error": "First message must be start action"}) + await websocket.close() + return + + # Parse stream configuration + stream_config = StreamConfig(**message.get("config", {})) + + # Override with server config if needed + stream_config.fps = min(stream_config.fps, config.capture.fps) + stream_config.quality = config.capture.quality + + # Add to stream manager + await stream_manager.add_stream(str(client_id), websocket, stream_config) + + # Send confirmation + await websocket.send_json({"type": "config", "data": stream_config.model_dump()}) + + # Get screen capture instance + capture = get_screen_capture() + + # Calculate frame interval + frame_interval = 1.0 / stream_config.fps + + # Streaming loop + while True: + try: + # Capture the region + image = capture.capture_region( + stream_config.region_x, + stream_config.region_y, + stream_config.region_width, + stream_config.region_height, + ) + + # Resize if needed + if stream_config.resolution != "native": + target_width, target_height = capture.get_resolution_preset(stream_config.resolution) + # Only resize if different from capture size + if target_width != stream_config.region_width or target_height != stream_config.region_height: + image = capture.resize_image(image, width=target_width) + + # Convert to base64 + base64_image = capture.image_to_base64(image, quality=stream_config.quality) + + # Send frame + frame_data = json.dumps( + {"type": "frame", "data": base64_image, "timestamp": asyncio.get_event_loop().time()} + ) + + await websocket.send_text(frame_data) + + # Wait for next frame + await asyncio.sleep(frame_interval) + + except WebSocketDisconnect: + logger.info(f"Client {client_id} disconnected") + break + except Exception as e: + logger.error(f"Error in stream loop for client {client_id}: {e}") + await websocket.send_json({"type": "error", "message": str(e)}) + break + + except Exception as e: + logger.error(f"WebSocket error for client {client_id}: {e}") + finally: + await stream_manager.remove_stream(str(client_id)) + logger.info(f"WebSocket closed: client {client_id}") + + +@router.post("/capture", response_model=Dict[str, Any]) +async def capture_screen_region(request: CaptureRequest): + """ + Capture a single frame from a screen region. + + This is useful for testing or getting a single snapshot. + """ + try: + capture = get_screen_capture() + + # Capture the region + image = capture.capture_region(request.x, request.y, request.width, request.height) + + if request.format == "base64": + # Convert to base64 + base64_image = capture.image_to_base64(image, quality=request.quality) + + return { + "success": True, + "format": "base64", + "data": base64_image, + "width": request.width, + "height": request.height, + } + else: + return {"success": False, "error": f"Unsupported format: {request.format}"} + + except Exception as e: + logger.error(f"Failed to capture region: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/regions") +async def get_regions(): + """ + Get predefined screen regions for capture. + + Returns common regions like map area, club indicator, etc. + """ + config = get_config() + + # Predefined regions for GSPro UI elements + regions = { + "map": { + "name": "Map Panel", + "x": config.capture.region_x, + "y": config.capture.region_y, + "width": config.capture.region_width, + "height": config.capture.region_height, + "description": "GSPro mini-map or expanded map view", + }, + "club": { + "name": "Club Indicator", + "x": 50, + "y": 200, + "width": 200, + "height": 100, + "description": "Current club selection display", + }, + "shot_info": { + "name": "Shot Information", + "x": 50, + "y": 50, + "width": 300, + "height": 150, + "description": "Shot distance and trajectory information", + }, + "scorecard": { + "name": "Scorecard", + "x": 400, + "y": 100, + "width": 800, + "height": 600, + "description": "Scorecard overlay when visible", + }, + } + + return {"regions": regions, "total": len(regions)} + + +@router.post("/regions/{region_name}") +async def update_region(region_name: str, region: RegionDefinition): + """ + Update or create a screen region definition. + + This allows users to define custom regions for their setup. + """ + config = get_config() + + if region_name == "map": + # Update the map region in config + config.capture.region_x = region.x + config.capture.region_y = region.y + config.capture.region_width = region.width + config.capture.region_height = region.height + config.save() + + return {"success": True, "message": f"Region '{region_name}' updated", "region": region.model_dump()} + else: + # For now, only map region is persisted + # V2 will add support for custom regions + return {"success": False, "message": "Custom regions not yet supported (V2 feature)"} + + +# OCR endpoints - gated behind vision config flag +@router.post("/ocr") +async def perform_ocr(request: CaptureRequest): + """ + Perform OCR on a screen region (V2 feature). + + This endpoint is only available when vision features are enabled. + """ + config = get_config() + + if not config.vision.enabled: + raise HTTPException(status_code=403, detail="Vision features are not enabled. This is a V2 feature.") + + # OCR implementation will go here in V2 + return {"success": False, "message": "OCR features coming in V2", "vision_enabled": config.vision.enabled} + + +@router.get("/markers") +async def get_markers(): + """ + Get visual markers for template matching (V2 feature). + + This endpoint is only available when vision features are enabled. + """ + config = get_config() + + if not config.vision.enabled: + raise HTTPException(status_code=403, detail="Vision features are not enabled. This is a V2 feature.") + + # Marker management will go here in V2 + return {"markers": [], "message": "Marker features coming in V2", "vision_enabled": config.vision.enabled} + + +@router.get("/status") +async def get_vision_status(): + """Get the status of vision features.""" + config = get_config() + + return { + "streaming_enabled": True, + "ocr_enabled": config.vision.enabled, + "markers_enabled": config.vision.enabled, + "active_streams": len(stream_manager.active_streams), + "capture_config": { + "fps": config.capture.fps, + "quality": config.capture.quality, + "resolution": config.capture.resolution, + }, + } diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..6eaef36 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,21 @@ +""" +Core modules for GSPro Remote backend. +""" + +from .config import AppConfig, get_config +from .input_ctrl import press_key, press_keys, key_down, key_up, focus_window, is_gspro_running +from .screen import capture_screen, get_screen_size, capture_region + +__all__ = [ + "AppConfig", + "get_config", + "press_key", + "press_keys", + "key_down", + "key_up", + "focus_window", + "is_gspro_running", + "capture_screen", + "get_screen_size", + "capture_region", +] diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..34dd4b3 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,193 @@ +""" +Configuration management for GSPro Remote. +""" + +import json +import logging +from pathlib import Path +from typing import Optional +from functools import lru_cache + +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings + +logger = logging.getLogger(__name__) + + +class ServerConfig(BaseModel): + """Server configuration settings.""" + + host: str = Field("0.0.0.0", description="Server host address") + port: int = Field(5005, description="Server port") + mdns_enabled: bool = Field(True, description="Enable mDNS service discovery") + + +class CaptureConfig(BaseModel): + """Screen capture configuration settings.""" + + fps: int = Field(30, description="Frames per second for streaming") + quality: int = Field(85, description="JPEG quality (0-100)") + resolution: str = Field("720p", description="Stream resolution") + region_x: int = Field(0, description="Map region X coordinate") + region_y: int = Field(0, description="Map region Y coordinate") + region_width: int = Field(640, description="Map region width") + region_height: int = Field(480, description="Map region height") + + +class GSProConfig(BaseModel): + """GSPro application configuration settings.""" + + window_title: str = Field("GSPro", description="GSPro window title") + auto_focus: bool = Field(True, description="Auto-focus GSPro window before sending keys") + key_delay: float = Field(0.05, description="Default delay between key presses (seconds)") + + +class VisionConfig(BaseModel): + """Computer vision configuration settings (for V2 features).""" + + enabled: bool = Field(False, description="Enable vision features") + ocr_engine: str = Field("easyocr", description="OCR engine to use (easyocr or tesseract)") + confidence_threshold: float = Field(0.7, description="Minimum confidence for OCR detection") + + +class AppConfig(BaseSettings): + """Main application configuration.""" + + server: ServerConfig = Field(default_factory=ServerConfig) + capture: CaptureConfig = Field(default_factory=CaptureConfig) + gspro: GSProConfig = Field(default_factory=GSProConfig) + vision: VisionConfig = Field(default_factory=VisionConfig) + + config_path: Optional[Path] = None + debug: bool = Field(False, description="Enable debug mode") + + class Config: + env_prefix = "GSPRO_REMOTE_" + env_nested_delimiter = "__" + case_sensitive = False + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if self.config_path is None: + self.config_path = self._get_default_config_path() + self.load() + + @staticmethod + def _get_default_config_path() -> Path: + """Get the default configuration file path.""" + import os + + if os.name == "nt": # Windows + base_path = Path(os.environ.get("LOCALAPPDATA", "")) + if not base_path: + base_path = Path.home() / "AppData" / "Local" + else: # Unix-like + base_path = Path.home() / ".config" + + config_dir = base_path / "GSPro Remote" + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir / "config.json" + + def load(self) -> None: + """Load configuration from file.""" + if self.config_path and self.config_path.exists(): + try: + with open(self.config_path, "r") as f: + data = json.load(f) + + # Update configuration with loaded data + if "server" in data: + self.server = ServerConfig(**data["server"]) + if "capture" in data: + self.capture = CaptureConfig(**data["capture"]) + if "gspro" in data: + self.gspro = GSProConfig(**data["gspro"]) + if "vision" in data: + self.vision = VisionConfig(**data["vision"]) + if "debug" in data: + self.debug = data["debug"] + + logger.info(f"Configuration loaded from {self.config_path}") + except Exception as e: + logger.warning(f"Failed to load configuration: {e}") + self.save() # Save default configuration + else: + # Create default configuration file + self.save() + logger.info(f"Created default configuration at {self.config_path}") + + def save(self) -> None: + """Save configuration to file.""" + if self.config_path: + try: + self.config_path.parent.mkdir(parents=True, exist_ok=True) + + data = { + "server": self.server.model_dump(), + "capture": self.capture.model_dump(), + "gspro": self.gspro.model_dump(), + "vision": self.vision.model_dump(), + "debug": self.debug, + } + + with open(self.config_path, "w") as f: + json.dump(data, f, indent=2) + + logger.info(f"Configuration saved to {self.config_path}") + except Exception as e: + logger.error(f"Failed to save configuration: {e}") + + def update(self, **kwargs) -> None: + """Update configuration with new values.""" + for key, value in kwargs.items(): + if hasattr(self, key): + if isinstance(value, dict): + # Update nested configuration + current = getattr(self, key) + if isinstance(current, BaseModel): + for sub_key, sub_value in value.items(): + if hasattr(current, sub_key): + setattr(current, sub_key, sub_value) + else: + setattr(self, key, value) + self.save() + + def reset(self) -> None: + """Reset configuration to defaults.""" + self.server = ServerConfig() + self.capture = CaptureConfig() + self.gspro = GSProConfig() + self.vision = VisionConfig() + self.debug = False + self.save() + + def to_dict(self) -> dict: + """Convert configuration to dictionary.""" + return { + "server": self.server.model_dump(), + "capture": self.capture.model_dump(), + "gspro": self.gspro.model_dump(), + "vision": self.vision.model_dump(), + "debug": self.debug, + "config_path": str(self.config_path) if self.config_path else None, + } + + +# Global configuration instance +_config: Optional[AppConfig] = None + + +@lru_cache(maxsize=1) +def get_config() -> AppConfig: + """Get the global configuration instance.""" + global _config + if _config is None: + _config = AppConfig() + return _config + + +def reset_config() -> None: + """Reset the global configuration instance.""" + global _config + _config = None + get_config.cache_clear() diff --git a/backend/app/core/input_ctrl.py b/backend/app/core/input_ctrl.py new file mode 100644 index 0000000..c234e12 --- /dev/null +++ b/backend/app/core/input_ctrl.py @@ -0,0 +1,350 @@ +""" +Windows input control module for simulating keyboard inputs to GSPro. +""" + +import logging +import time +from typing import Optional, List + +try: + import pydirectinput + import win32gui + import win32con + import win32process + import psutil +except ImportError as e: + raise ImportError(f"Required Windows dependencies not installed: {e}") + +logger = logging.getLogger(__name__) + +# Configure pydirectinput +pydirectinput.PAUSE = 0.01 # Reduce default pause between actions + + +def is_gspro_running(window_title: str = "GSPro") -> bool: + """ + Check if GSPro is running by looking for its window. + + Args: + window_title: The window title to search for + + Returns: + True if GSPro window is found, False otherwise + """ + + def enum_window_callback(hwnd, windows): + if win32gui.IsWindowVisible(hwnd) and win32gui.IsWindowEnabled(hwnd): + window_text = win32gui.GetWindowText(hwnd) + if window_title.lower() in window_text.lower(): + windows.append(hwnd) + return True + + windows = [] + win32gui.EnumWindows(enum_window_callback, windows) + return len(windows) > 0 + + +def find_gspro_window(window_title: str = "GSPro") -> Optional[int]: + """ + Find the GSPro window handle. + + Args: + window_title: The window title to search for + + Returns: + Window handle if found, None otherwise + """ + + def enum_window_callback(hwnd, result): + window_text = win32gui.GetWindowText(hwnd) + if window_title.lower() in window_text.lower(): + result.append(hwnd) + return True + + result = [] + win32gui.EnumWindows(enum_window_callback, result) + + if result: + return result[0] + return None + + +def focus_window(window_title: str = "GSPro") -> bool: + """ + Focus the GSPro window to ensure it receives keyboard input. + + Args: + window_title: The window title to focus + + Returns: + True if window was focused successfully, False otherwise + """ + try: + hwnd = find_gspro_window(window_title) + if hwnd: + # Restore window if minimized + if win32gui.IsIconic(hwnd): + win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) + + # Set foreground window + win32gui.SetForegroundWindow(hwnd) + + # Small delay to ensure window is focused + time.sleep(0.1) + + logger.debug(f"Focused window: {window_title}") + return True + else: + logger.warning(f"Window not found: {window_title}") + return False + except Exception as e: + logger.error(f"Failed to focus window: {e}") + return False + + +def press_key(key: str, interval: float = 0.0) -> None: + """ + Simulate a single key press. + + Args: + key: The key to press (e.g., 'a', 'space', 'f1', 'up') + interval: Time to wait after pressing the key + """ + try: + # Normalize key name for pydirectinput + key_normalized = key.lower().strip() + + # Handle special key mappings + key_mappings = { + "ctrl": "ctrl", + "control": "ctrl", + "alt": "alt", + "shift": "shift", + "tab": "tab", + "space": "space", + "enter": "enter", + "return": "enter", + "escape": "esc", + "esc": "esc", + "backspace": "backspace", + "delete": "delete", + "del": "delete", + "insert": "insert", + "ins": "insert", + "home": "home", + "end": "end", + "pageup": "pageup", + "pagedown": "pagedown", + "up": "up", + "down": "down", + "left": "left", + "right": "right", + "plus": "+", + "minus": "-", + "apostrophe": "'", + "quote": "'", + } + + # Map key if needed + key_to_press = key_mappings.get(key_normalized, key_normalized) + + # Press the key + pydirectinput.press(key_to_press) + + if interval > 0: + time.sleep(interval) + + logger.debug(f"Pressed key: {key}") + except Exception as e: + logger.error(f"Failed to press key '{key}': {e}") + raise + + +def press_keys(keys: str, interval: float = 0.0) -> None: + """ + Simulate a key combination or sequence. + + Args: + keys: Key combination string (e.g., 'ctrl+m', 'shift+tab') + interval: Time to wait after pressing the keys + """ + try: + # Check if it's a key combination + if "+" in keys: + # Split into modifiers and key + parts = keys.lower().split("+") + modifiers = [] + main_key = parts[-1] + + # Identify modifiers + for part in parts[:-1]: + if part in ["ctrl", "control"]: + modifiers.append("ctrl") + elif part in ["alt"]: + modifiers.append("alt") + elif part in ["shift"]: + modifiers.append("shift") + elif part in ["win", "windows", "cmd", "command"]: + modifiers.append("win") + + # Press combination using hotkey + if modifiers: + hotkey_parts = modifiers + [main_key] + pydirectinput.hotkey(*hotkey_parts) + else: + press_key(main_key) + else: + # Single key press + press_key(keys) + + if interval > 0: + time.sleep(interval) + + logger.debug(f"Pressed keys: {keys}") + except Exception as e: + logger.error(f"Failed to press keys '{keys}': {e}") + raise + + +def key_down(key: str) -> None: + """ + Hold a key down. + + Args: + key: The key to hold down + """ + try: + key_normalized = key.lower().strip() + pydirectinput.keyDown(key_normalized) + logger.debug(f"Key down: {key}") + except Exception as e: + logger.error(f"Failed to hold key down '{key}': {e}") + raise + + +def key_up(key: str) -> None: + """ + Release a held key. + + Args: + key: The key to release + """ + try: + key_normalized = key.lower().strip() + pydirectinput.keyUp(key_normalized) + logger.debug(f"Key up: {key}") + except Exception as e: + logger.error(f"Failed to release key '{key}': {e}") + raise + + +def type_text(text: str, interval: float = 0.0) -> None: + """ + Type a string of text. + + Args: + text: The text to type + interval: Time between each character + """ + try: + pydirectinput.typewrite(text, interval=interval) + logger.debug(f"Typed text: {text[:20]}...") + except Exception as e: + logger.error(f"Failed to type text: {e}") + raise + + +def mouse_click(x: Optional[int] = None, y: Optional[int] = None, button: str = "left") -> None: + """ + Simulate a mouse click. + + Args: + x: X coordinate (None for current position) + y: Y coordinate (None for current position) + button: Mouse button ('left', 'right', 'middle') + """ + try: + if x is not None and y is not None: + pydirectinput.click(x, y, button=button) + logger.debug(f"Mouse click at ({x}, {y}) with {button} button") + else: + pydirectinput.click(button=button) + logger.debug(f"Mouse click with {button} button") + except Exception as e: + logger.error(f"Failed to perform mouse click: {e}") + raise + + +def mouse_move(x: int, y: int, duration: float = 0.0) -> None: + """ + Move the mouse cursor. + + Args: + x: Target X coordinate + y: Target Y coordinate + duration: Time to take for the movement + """ + try: + if duration > 0: + pydirectinput.moveTo(x, y, duration=duration) + else: + pydirectinput.moveTo(x, y) + logger.debug(f"Mouse moved to ({x}, {y})") + except Exception as e: + logger.error(f"Failed to move mouse: {e}") + raise + + +def get_gspro_process_info() -> Optional[dict]: + """ + Get information about the GSPro process if it's running. + + Returns: + Dictionary with process info or None if not found + """ + try: + for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info"]): + if "gspro" in proc.info["name"].lower(): + return { + "pid": proc.info["pid"], + "name": proc.info["name"], + "cpu_percent": proc.info["cpu_percent"], + "memory_mb": proc.info["memory_info"].rss / 1024 / 1024 if proc.info["memory_info"] else 0, + } + except Exception as e: + logger.error(f"Failed to get GSPro process info: {e}") + return None + + +# Test function for development +def test_input_control(): + """Test function to verify input control is working.""" + print("Testing input control...") + + # Check if GSPro is running + if is_gspro_running(): + print("✓ GSPro is running") + + # Try to focus the window + if focus_window(): + print("✓ GSPro window focused") + else: + print("✗ Could not focus GSPro window") + else: + print("✗ GSPro is not running") + print("Please start GSPro and try again") + return + + # Get process info + info = get_gspro_process_info() + if info: + print( + f"✓ GSPro process found: PID={info['pid']}, CPU={info['cpu_percent']:.1f}%, Memory={info['memory_mb']:.1f}MB" + ) + + print("\nInput control test complete!") + + +if __name__ == "__main__": + # Run test when module is executed directly + test_input_control() diff --git a/backend/app/core/mdns.py b/backend/app/core/mdns.py new file mode 100644 index 0000000..a46e338 --- /dev/null +++ b/backend/app/core/mdns.py @@ -0,0 +1,335 @@ +""" +mDNS service registration for GSPro Remote. +Allows the application to be discovered on the local network. +""" + +import logging +import socket +import threading +from typing import Optional, Dict, Any + +try: + from zeroconf import ServiceInfo, Zeroconf, IPVersion +except ImportError as e: + raise ImportError(f"Zeroconf library not installed: {e}") + +logger = logging.getLogger(__name__) + + +class MDNSService: + """ + Manages mDNS/Bonjour service registration for network discovery. + """ + + def __init__( + self, + name: str = "gsproapp", + port: int = 5005, + service_type: str = "_http._tcp.local.", + properties: Optional[Dict[str, Any]] = None, + ): + """ + Initialize mDNS service. + + Args: + name: Service name (will be accessible as {name}.local) + port: Port number the service is running on + service_type: mDNS service type + properties: Additional service properties + """ + self.name = name + self.port = port + self.service_type = service_type + self.properties = properties or {} + + self.zeroconf: Optional[Zeroconf] = None + self.service_info: Optional[ServiceInfo] = None + self.is_running = False + self._lock = threading.Lock() + + def _get_local_ip(self) -> str: + """ + Get the local IP address of the machine. + + Returns: + Local IP address as string + """ + try: + # Create a socket to determine the local IP + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: + # Connect to a public DNS server to determine local interface + s.connect(("8.8.8.8", 80)) + return s.getsockname()[0] + except Exception: + # Fallback to localhost if can't determine + return "127.0.0.1" + + def _create_service_info(self) -> ServiceInfo: + """ + Create the ServiceInfo object for registration. + + Returns: + Configured ServiceInfo object + """ + local_ip = self._get_local_ip() + hostname = socket.gethostname() + + # Create fully qualified service name + service_name = f"{self.name}.{self.service_type}" + + # Add default properties + default_properties = { + "version": "0.1.0", + "platform": "windows", + "api": "rest", + "ui": "web", + } + + # Merge with custom properties + all_properties = {**default_properties, **self.properties} + + # Convert properties to bytes + properties_bytes = {} + for key, value in all_properties.items(): + if isinstance(value, str): + properties_bytes[key] = value.encode("utf-8") + elif isinstance(value, bytes): + properties_bytes[key] = value + else: + properties_bytes[key] = str(value).encode("utf-8") + + # Create service info + service_info = ServiceInfo( + type_=self.service_type, + name=service_name, + addresses=[socket.inet_aton(local_ip)], + port=self.port, + properties=properties_bytes, + server=f"{hostname}.local.", + ) + + return service_info + + def start(self) -> bool: + """ + Start the mDNS service registration. + + Returns: + True if service started successfully, False otherwise + """ + with self._lock: + if self.is_running: + logger.warning("mDNS service is already running") + return True + + try: + # Create Zeroconf instance + self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only) + + # Create and register service + self.service_info = self._create_service_info() + self.zeroconf.register_service(self.service_info) + + self.is_running = True + + logger.info(f"mDNS service registered: {self.name}.local:{self.port} (type: {self.service_type})") + + return True + + except Exception as e: + logger.error(f"Failed to start mDNS service: {e}") + self.cleanup() + return False + + def stop(self) -> None: + """Stop the mDNS service registration.""" + with self._lock: + if not self.is_running: + return + + self.cleanup() + self.is_running = False + logger.info("mDNS service stopped") + + def cleanup(self) -> None: + """Clean up mDNS resources.""" + try: + if self.zeroconf and self.service_info: + self.zeroconf.unregister_service(self.service_info) + + if self.zeroconf: + self.zeroconf.close() + self.zeroconf = None + + self.service_info = None + + except Exception as e: + logger.error(f"Error during mDNS cleanup: {e}") + + def update_properties(self, properties: Dict[str, Any]) -> bool: + """ + Update service properties. + + Args: + properties: New properties to set + + Returns: + True if properties updated successfully, False otherwise + """ + with self._lock: + if not self.is_running: + logger.warning("Cannot update properties: service is not running") + return False + + try: + self.properties.update(properties) + + # Recreate and re-register service with new properties + if self.zeroconf and self.service_info: + self.zeroconf.unregister_service(self.service_info) + self.service_info = self._create_service_info() + self.zeroconf.register_service(self.service_info) + + logger.info("mDNS service properties updated") + return True + + except Exception as e: + logger.error(f"Failed to update mDNS properties: {e}") + + return False + + def get_url(self) -> str: + """ + Get the URL for accessing the service. + + Returns: + Service URL + """ + return f"http://{self.name}.local:{self.port}" + + def __enter__(self): + """Context manager entry.""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.stop() + + +class MDNSBrowser: + """ + Browse for mDNS services on the network. + Useful for discovering other GSPro Remote instances. + """ + + def __init__(self, service_type: str = "_http._tcp.local."): + """ + Initialize mDNS browser. + + Args: + service_type: Type of services to browse for + """ + self.service_type = service_type + self.services: Dict[str, Dict[str, Any]] = {} + self.zeroconf: Optional[Zeroconf] = None + + def browse(self, timeout: float = 5.0) -> Dict[str, Dict[str, Any]]: + """ + Browse for services on the network. + + Args: + timeout: Time to wait for services (seconds) + + Returns: + Dictionary of discovered services + """ + try: + from zeroconf import ServiceBrowser, ServiceListener + import time + + class Listener(ServiceListener): + def __init__(self, browser): + self.browser = browser + + def add_service(self, zeroconf, service_type, name): + info = zeroconf.get_service_info(service_type, name) + if info: + self.browser.services[name] = { + "name": name, + "address": socket.inet_ntoa(info.addresses[0]) if info.addresses else None, + "port": info.port, + "properties": info.properties, + } + + def remove_service(self, zeroconf, service_type, name): + self.browser.services.pop(name, None) + + def update_service(self, zeroconf, service_type, name): + pass + + self.zeroconf = Zeroconf(ip_version=IPVersion.V4Only) + listener = Listener(self) + browser = ServiceBrowser(self.zeroconf, self.service_type, listener) + + # Wait for services to be discovered + time.sleep(timeout) + + browser.cancel() + self.zeroconf.close() + + return self.services + + except Exception as e: + logger.error(f"Failed to browse for services: {e}") + return {} + + +# Test function for development +def test_mdns_service(): + """Test mDNS service registration.""" + import time + + print("Testing mDNS service registration...") + + # Test service registration + service = MDNSService( + name="gsproapp-test", + port=5005, + properties={"test": "true", "instance": "development"}, + ) + + if service.start(): + print(f"✓ mDNS service started: {service.get_url()}") + print(f" You should be able to access it at: http://gsproapp-test.local:5005") + + # Keep service running for 10 seconds + print(" Service will run for 10 seconds...") + time.sleep(10) + + # Test property update + if service.update_properties({"status": "running", "uptime": "10s"}): + print("✓ Properties updated successfully") + + service.stop() + print("✓ mDNS service stopped") + else: + print("✗ Failed to start mDNS service") + + # Test service browsing + print("\nBrowsing for HTTP services on the network...") + browser = MDNSBrowser() + services = browser.browse(timeout=3.0) + + if services: + print(f"Found {len(services)} services:") + for name, info in services.items(): + print(f" - {name}: {info['address']}:{info['port']}") + else: + print("No services found") + + print("\nmDNS test complete!") + + +if __name__ == "__main__": + test_mdns_service() diff --git a/backend/app/core/screen.py b/backend/app/core/screen.py new file mode 100644 index 0000000..a63500f --- /dev/null +++ b/backend/app/core/screen.py @@ -0,0 +1,370 @@ +""" +Screen capture utilities for GSPro Remote. +""" + +import logging +from typing import Optional, Tuple, Dict, Any +from io import BytesIO +import base64 + +try: + import mss + import mss.tools + from PIL import Image + import cv2 + import numpy as np +except ImportError as e: + raise ImportError(f"Required screen capture dependencies not installed: {e}") + +logger = logging.getLogger(__name__) + + +class ScreenCapture: + """Manages screen capture operations.""" + + def __init__(self): + """Initialize screen capture manager.""" + self.sct = mss.mss() + self._monitor_info = None + + def get_monitors(self) -> list[dict]: + """ + Get information about all available monitors. + + Returns: + List of monitor information dictionaries + """ + monitors = [] + for i, monitor in enumerate(self.sct.monitors): + monitors.append( + { + "index": i, + "left": monitor["left"], + "top": monitor["top"], + "width": monitor["width"], + "height": monitor["height"], + "is_primary": i == 0, # Index 0 is combined virtual screen + } + ) + return monitors + + def get_primary_monitor(self) -> dict: + """ + Get the primary monitor information. + + Returns: + Primary monitor information + """ + # Index 1 is typically the primary monitor in mss + return self.sct.monitors[1] if len(self.sct.monitors) > 1 else self.sct.monitors[0] + + def capture_screen(self, monitor_index: int = 1) -> np.ndarray: + """ + Capture the entire screen. + + Args: + monitor_index: Index of the monitor to capture (0 for all, 1 for primary) + + Returns: + Captured screen as numpy array (BGR format) + """ + try: + monitor = self.sct.monitors[monitor_index] + screenshot = self.sct.grab(monitor) + + # Convert to numpy array (BGR format for OpenCV compatibility) + img = np.array(screenshot) + img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR) + + return img + except Exception as e: + logger.error(f"Failed to capture screen: {e}") + raise + + def capture_region(self, x: int, y: int, width: int, height: int, monitor_index: int = 1) -> np.ndarray: + """ + Capture a specific region of the screen. + + Args: + x: X coordinate of the region (relative to monitor) + y: Y coordinate of the region (relative to monitor) + width: Width of the region + height: Height of the region + monitor_index: Index of the monitor to capture from + + Returns: + Captured region as numpy array (BGR format) + """ + try: + monitor = self.sct.monitors[monitor_index] + + # Define region to capture + region = { + "left": monitor["left"] + x, + "top": monitor["top"] + y, + "width": width, + "height": height, + } + + screenshot = self.sct.grab(region) + + # Convert to numpy array + img = np.array(screenshot) + img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR) + + return img + except Exception as e: + logger.error(f"Failed to capture region: {e}") + raise + + def capture_window(self, window_title: str) -> Optional[np.ndarray]: + """ + Capture a specific window by title. + + Args: + window_title: Title of the window to capture + + Returns: + Captured window as numpy array or None if window not found + """ + try: + import win32gui + + def enum_window_callback(hwnd, windows): + if win32gui.IsWindowVisible(hwnd) and win32gui.IsWindowEnabled(hwnd): + window_text = win32gui.GetWindowText(hwnd) + if window_title.lower() in window_text.lower(): + windows.append(hwnd) + return True + + windows = [] + win32gui.EnumWindows(enum_window_callback, windows) + + if not windows: + logger.warning(f"Window not found: {window_title}") + return None + + # Get window rectangle + hwnd = windows[0] + rect = win32gui.GetWindowRect(hwnd) + x, y, x2, y2 = rect + width = x2 - x + height = y2 - y + + # Capture the window region + return self.capture_region(x, y, width, height, monitor_index=0) + + except Exception as e: + logger.error(f"Failed to capture window: {e}") + return None + + def image_to_base64(self, image: np.ndarray, quality: int = 85, format: str = "JPEG") -> str: + """ + Convert an image array to base64 string. + + Args: + image: Image as numpy array (BGR format) + quality: JPEG quality (1-100) + format: Image format (JPEG, PNG) + + Returns: + Base64 encoded image string + """ + try: + # Convert BGR to RGB for PIL + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(rgb_image) + + # Save to bytes + buffer = BytesIO() + if format.upper() == "JPEG": + pil_image.save(buffer, format=format, quality=quality, optimize=True) + else: + pil_image.save(buffer, format=format) + + # Encode to base64 + buffer.seek(0) + base64_string = base64.b64encode(buffer.getvalue()).decode("utf-8") + + return base64_string + except Exception as e: + logger.error(f"Failed to convert image to base64: {e}") + raise + + def resize_image(self, image: np.ndarray, width: Optional[int] = None, height: Optional[int] = None) -> np.ndarray: + """ + Resize an image while maintaining aspect ratio. + + Args: + image: Image as numpy array + width: Target width (None to calculate from height) + height: Target height (None to calculate from width) + + Returns: + Resized image as numpy array + """ + try: + h, w = image.shape[:2] + + if width and not height: + # Calculate height maintaining aspect ratio + height = int(h * (width / w)) + elif height and not width: + # Calculate width maintaining aspect ratio + width = int(w * (height / h)) + elif not width and not height: + # No resize needed + return image + + return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA) + except Exception as e: + logger.error(f"Failed to resize image: {e}") + raise + + def get_resolution_preset(self, preset: str) -> Tuple[int, int]: + """ + Get width and height for a resolution preset. + + Args: + preset: Resolution preset (e.g., '720p', '1080p', '480p') + + Returns: + Tuple of (width, height) + """ + presets = { + "480p": (854, 480), + "540p": (960, 540), + "720p": (1280, 720), + "900p": (1600, 900), + "1080p": (1920, 1080), + "1440p": (2560, 1440), + "2160p": (3840, 2160), + "4k": (3840, 2160), + } + + return presets.get(preset.lower(), (1280, 720)) + + def close(self): + """Close the screen capture resources.""" + if hasattr(self, "sct"): + self.sct.close() + + +# Global screen capture instance +_screen_capture: Optional[ScreenCapture] = None + + +def get_screen_capture() -> ScreenCapture: + """ + Get the global screen capture instance. + + Returns: + ScreenCapture instance + """ + global _screen_capture + if _screen_capture is None: + _screen_capture = ScreenCapture() + return _screen_capture + + +def capture_screen(monitor_index: int = 1) -> np.ndarray: + """ + Capture the entire screen. + + Args: + monitor_index: Index of the monitor to capture + + Returns: + Captured screen as numpy array + """ + return get_screen_capture().capture_screen(monitor_index) + + +def capture_region(x: int, y: int, width: int, height: int) -> np.ndarray: + """ + Capture a specific region of the screen. + + Args: + x: X coordinate of the region + y: Y coordinate of the region + width: Width of the region + height: Height of the region + + Returns: + Captured region as numpy array + """ + return get_screen_capture().capture_region(x, y, width, height) + + +def get_screen_size() -> Tuple[int, int]: + """ + Get the primary screen size. + + Returns: + Tuple of (width, height) + """ + monitor = get_screen_capture().get_primary_monitor() + return monitor["width"], monitor["height"] + + +def capture_gspro_window(window_title: str = "GSPro") -> Optional[np.ndarray]: + """ + Capture the GSPro window. + + Args: + window_title: GSPro window title + + Returns: + Captured window as numpy array or None if not found + """ + return get_screen_capture().capture_window(window_title) + + +# Test function for development +def test_screen_capture(): + """Test screen capture functionality.""" + print("Testing screen capture...") + + capture = ScreenCapture() + + # Get monitor information + monitors = capture.get_monitors() + print(f"Found {len(monitors)} monitors:") + for monitor in monitors: + print( + f" Monitor {monitor['index']}: {monitor['width']}x{monitor['height']} at ({monitor['left']}, {monitor['top']})" + ) + + # Capture primary screen + try: + screen = capture.capture_screen() + print(f"✓ Captured primary screen: {screen.shape}") + except Exception as e: + print(f"✗ Failed to capture screen: {e}") + + # Test region capture + try: + region = capture.capture_region(100, 100, 640, 480) + print(f"✓ Captured region: {region.shape}") + except Exception as e: + print(f"✗ Failed to capture region: {e}") + + # Test image to base64 conversion + try: + base64_str = capture.image_to_base64(region) + print(f"✓ Converted to base64: {len(base64_str)} chars") + except Exception as e: + print(f"✗ Failed to convert to base64: {e}") + + # Test resolution presets + presets = ["480p", "720p", "1080p"] + for preset in presets: + width, height = capture.get_resolution_preset(preset) + print(f" {preset}: {width}x{height}") + + capture.close() + print("\nScreen capture test complete!") + + +if __name__ == "__main__": + test_screen_capture() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0549ae6 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,115 @@ +""" +Main FastAPI application for GSPro Remote backend. +""" + +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from .api import actions, config, system, vision +from .core.config import AppConfig, get_config +from .core.mdns import MDNSService + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Application lifespan manager for startup/shutdown tasks. + """ + # Startup + logger.info("Starting GSPro Remote backend v0.1.0") + + # Load configuration + config = get_config() + logger.info(f"Configuration loaded from {config.config_path}") + + # Start mDNS service if enabled + mdns_service = None + if config.server.mdns_enabled: + try: + mdns_service = MDNSService(name="gsproapp", port=config.server.port, service_type="_http._tcp.local.") + mdns_service.start() + logger.info(f"mDNS service started: gsproapp.local:{config.server.port}") + except Exception as e: + logger.warning(f"Failed to start mDNS service: {e}") + + yield + + # Shutdown + logger.info("Shutting down GSPro Remote backend") + + # Stop mDNS service + if mdns_service: + mdns_service.stop() + logger.info("mDNS service stopped") + + # Save configuration + config.save() + logger.info("Configuration saved") + + +def create_app() -> FastAPI: + """ + Create and configure the FastAPI application. + """ + app = FastAPI( + title="GSPro Remote", + version="0.1.0", + description="Remote control API for GSPro golf simulator", + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", + lifespan=lifespan, + ) + + # Configure CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify actual origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Mount API routers + app.include_router(actions.router, prefix="/api/actions", tags=["Actions"]) + app.include_router(config.router, prefix="/api/config", tags=["Configuration"]) + app.include_router(vision.router, prefix="/api/vision", tags=["Vision"]) + app.include_router(system.router, prefix="/api/system", tags=["System"]) + + # Serve frontend UI if built + ui_path = Path(__file__).parent.parent / "ui" + if ui_path.exists(): + app.mount("/ui", StaticFiles(directory=str(ui_path), html=True), name="ui") + logger.info(f"Serving UI from {ui_path}") + + # Root redirect + @app.get("/") + async def root(): + return { + "name": "GSPro Remote", + "version": "0.1.0", + "status": "running", + "ui": "/ui" if ui_path.exists() else None, + "docs": "/api/docs", + } + + return app + + +# Create the application instance +app = create_app() + +if __name__ == "__main__": + import uvicorn + + config = get_config() + uvicorn.run("app.main:app", host=config.server.host, port=config.server.port, reload=True, log_level="info") diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..1a0d864 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,76 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "gspro-remote" +version = "0.1.0" +description = "Remote control application for GSPro golf simulator" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "GSPro Remote Team"}, +] +dependencies = [ + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "pydirectinput>=1.0.4", + "pywin32>=306", + "mss>=9.0.1", + "opencv-python>=4.8.0", + "pillow>=10.1.0", + "zeroconf>=0.120.0", + "websockets>=12.0", + "python-multipart>=0.0.6", + "aiofiles>=23.2.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "black>=23.11.0", + "ruff>=0.1.0", + "mypy>=1.7.0", + "httpx>=0.25.0", +] +vision = [ + "easyocr>=1.7.0", + "pytesseract>=0.3.10", + "numpy>=1.24.0", +] +build = [ + "pyinstaller>=6.0.0", + "nuitka>=1.8.0", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] +exclude = ["tests*"] + +[tool.black] +line-length = 120 +target-version = ['py311'] + +[tool.ruff] +line-length = 120 +select = ["E", "F", "I", "N", "W"] +ignore = ["E501"] +target-version = "py311" + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --cov=app --cov-report=html --cov-report=term" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9235e4b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,27 @@ +# Core dependencies +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 + +# Windows input control +pydirectinput>=1.0.4 +pywin32>=306; sys_platform == 'win32' + +# Screen capture and image processing +mss>=9.0.1 +opencv-python>=4.8.0 +pillow>=10.1.0 + +# Networking and service discovery +zeroconf>=0.120.0 +websockets>=12.0 +python-multipart>=0.0.6 +aiofiles>=23.2.1 + +# System utilities +psutil>=5.9.0 +numpy>=1.24.0 + +# HTTP client for testing +httpx>=0.25.0 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1dbbe54 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + GSPro Remote + + + + + +
+
+ +

GSPro Remote

+

Connecting to GSPro...

+
+
+
+ + +
+ + + + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3fd6a6b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4813 @@ +{ + "name": "gspro-remote-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gspro-remote-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.6.2", + "clsx": "^2.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hot-toast": "^2.4.1", + "react-icons": "^4.12.0", + "zustand": "^4.4.7" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } + }, + "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/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "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" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "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/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "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/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/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/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/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "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", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "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/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "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.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.27.tgz", + "integrity": "sha512-2CXFpkjVnY2FT+B6GrSYxzYf65BJWEqz5tIRHCvNsZZ2F3CmsCB37h8SpYgKG7y9C4YAeTipIPWG7EmFmhAeXA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "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": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "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" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "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", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "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_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/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.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/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/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.250", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.250.tgz", + "integrity": "sha512-/5UMj9IiGDMOFBnN4i7/Ry5onJrAGSbOGo3s9FEKmwobGq6xw832ccET0CE3CkkMBZ8GJSlUIesZofpyurqDXw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.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-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "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/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "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==", + "dev": true, + "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", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "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-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "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-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "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", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/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" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "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.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/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", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-icons": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.12.0.tgz", + "integrity": "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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", + "dependencies": { + "pify": "^2.3.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, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, + "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" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "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", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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": { + "has-flag": "^4.0.0" + }, + "engines": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "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_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "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==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.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/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a5dd15c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "gspro-remote-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "axios": "^1.6.2", + "clsx": "^2.0.0", + "react-icons": "^4.12.0", + "zustand": "^4.4.7", + "react-hot-toast": "^2.4.1" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..2855478 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,60 @@ +{ + "name": "GSPro Remote", + "short_name": "GSPro Remote", + "description": "Remote control application for GSPro golf simulator", + "version": "0.1.0", + "manifest_version": 3, + "start_url": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0f172a", + "theme_color": "#2D5016", + "categories": ["sports", "games", "utilities"], + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Quick Launch", + "short_name": "Launch", + "description": "Quick launch GSPro Remote", + "url": "/", + "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] + } + ], + "screenshots": [ + { + "src": "/screenshot1.png", + "type": "image/png", + "sizes": "1280x720", + "label": "Main control interface" + }, + { + "src": "/screenshot2.png", + "type": "image/png", + "sizes": "1280x720", + "label": "Map view with streaming" + } + ], + "related_applications": [], + "prefer_related_applications": false, + "scope": "/", + "launch_handler": { + "client_mode": "navigate-existing" + }, + "display_override": ["window-controls-overlay", "standalone", "minimal-ui"], + "edge_side_panel": { + "preferred_width": 480 + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e480271 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react' +import { Toaster } from 'react-hot-toast' +import DynamicGolfUI from './pages/DynamicGolfUI' +import ConnectionStatus from './components/ConnectionStatus' +import ErrorBoundary from './components/ErrorBoundary' +import { useStore } from './stores/appStore' +import { checkBackendConnection } from './api/system' + +function App() { + const [isConnected, setIsConnected] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const { setConnectionStatus } = useStore() + + useEffect(() => { + // Check backend connection on mount + const checkConnection = async () => { + try { + const result = await checkBackendConnection() + setIsConnected(result) + setConnectionStatus(result) + } catch (error) { + console.error('Failed to connect to backend:', error) + setIsConnected(false) + setConnectionStatus(false) + } finally { + setIsLoading(false) + } + } + + checkConnection() + + // Set up periodic connection check + const interval = setInterval(checkConnection, 5000) + + return () => clearInterval(interval) + }, [setConnectionStatus]) + + if (isLoading) { + return ( +
+
+
+

Connecting to GSPro Remote...

+
+
+ ) + } + + if (!isConnected) { + return ( +
+
+
+
+ + + +
+

Connection Error

+

+ Unable to connect to the GSPro Remote backend server. +

+
+

Please ensure:

+
    +
  • • The backend server is running
  • +
  • • You're on the same network
  • +
  • • Port 5005 is accessible
  • +
+
+ +
+
+
+ ) + } + + return ( + +
+ {/* Connection Status Bar */} + + + {/* Main UI */} + + + {/* Toast Notifications */} + +
+
+ ) +} + +export default App diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..f86b708 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,308 @@ +import axios, { AxiosInstance, AxiosError } from "axios"; +import toast from "react-hot-toast"; + +// Determine API base URL based on current location +const getApiBaseUrl = () => { + // If we're running on localhost (development), use localhost backend + if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") { + return "http://localhost:5005"; + } + + // If we're accessing from a network IP, use the same IP for backend + // This assumes backend is running on same machine as frontend + const protocol = window.location.protocol; + const hostname = window.location.hostname; + return `${protocol}//${hostname}:5005`; +}; + +const API_BASE_URL = import.meta.env.VITE_API_URL || getApiBaseUrl(); + +// Log the API URL for debugging +console.log("API Base URL:", API_BASE_URL); + +// Create axios instance with default config +const apiClient: AxiosInstance = axios.create({ + baseURL: API_BASE_URL, + timeout: 10000, + headers: { + "Content-Type": "application/json", + }, +}); + +// Request interceptor +apiClient.interceptors.request.use( + (config) => { + // Add any auth tokens or request modifications here + return config; + }, + (error) => { + return Promise.reject(error); + }, +); + +// Response interceptor +apiClient.interceptors.response.use( + (response) => { + return response; + }, + (error: AxiosError) => { + // Handle common errors + if (error.code === "ECONNABORTED") { + toast.error("Request timeout - please try again"); + } else if (error.code === "ERR_NETWORK") { + toast.error("Cannot connect to backend - check if server is running"); + } else if (error.response) { + // Server responded with error + const message = (error.response.data as any)?.detail || "An error occurred"; + + if (error.response.status === 409) { + // GSPro not running + toast.error("GSPro is not running or window not found"); + } else if (error.response.status >= 500) { + toast.error(`Server error: ${message}`); + } + } else if (error.request) { + // No response from server + console.error("Backend connection failed:", error.message); + toast.error("Cannot connect to backend server on " + API_BASE_URL); + } + + return Promise.reject(error); + }, +); + +// Actions API +export const actionsAPI = { + sendKey: async (key: string, delay?: number) => { + const response = await apiClient.post("/api/actions/key", { key, delay }); + return response.data; + }, + + sendKeyDown: async (key: string, duration?: number) => { + const response = await apiClient.post("/api/actions/keydown", { key, duration }); + return response.data; + }, + + sendKeyUp: async (key: string) => { + const response = await apiClient.post("/api/actions/keyup", { key }); + return response.data; + }, + + sendKeySequence: async (keys: string[], interval?: number) => { + const response = await apiClient.post("/api/actions/sequence", { keys, interval }); + return response.data; + }, + + getShortcuts: async () => { + const response = await apiClient.get("/api/actions/shortcuts"); + return response.data; + }, +}; + +// Config API +export const configAPI = { + getConfig: async () => { + const response = await apiClient.get("/api/config"); + return response.data; + }, + + updateConfig: async (config: any) => { + const response = await apiClient.put("/api/config", config); + return response.data; + }, + + saveConfig: async () => { + const response = await apiClient.post("/api/config/save"); + return response.data; + }, + + resetConfig: async () => { + const response = await apiClient.post("/api/config/reset"); + return response.data; + }, +}; + +// Vision API +export const visionAPI = { + captureRegion: async (x: number, y: number, width: number, height: number) => { + const response = await apiClient.post("/api/vision/capture", { + x, + y, + width, + height, + format: "base64", + quality: 85, + }); + return response.data; + }, + + getRegions: async () => { + const response = await apiClient.get("/api/vision/regions"); + return response.data; + }, + + updateRegion: async (name: string, region: any) => { + const response = await apiClient.post(`/api/vision/regions/${name}`, region); + return response.data; + }, + + getStatus: async () => { + const response = await apiClient.get("/api/vision/status"); + return response.data; + }, +}; + +// System API +export const systemAPI = { + getHealth: async () => { + const response = await apiClient.get("/api/system/health"); + return response.data; + }, + + getInfo: async () => { + const response = await apiClient.get("/api/system/info"); + return response.data; + }, + + getGSProStatus: async () => { + const response = await apiClient.get("/api/system/gspro/status"); + return response.data; + }, + + findGSProWindow: async () => { + const response = await apiClient.get("/api/system/gspro/find"); + return response.data; + }, + + getMetrics: async () => { + const response = await apiClient.get("/api/system/metrics"); + return response.data; + }, + + runDiagnostics: async () => { + const response = await apiClient.get("/api/system/diagnostics"); + return response.data; + }, +}; + +// WebSocket connection for streaming +export class StreamingClient { + private ws: WebSocket | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + + constructor( + private onFrame: (data: any) => void, + private onError?: (error: any) => void, + private onConnect?: () => void, + private onDisconnect?: () => void, + ) {} + + connect(config: any = {}) { + // Use same logic for WebSocket URL + let wsUrl: string; + if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") { + wsUrl = "ws://localhost:5005"; + } else { + // Use the same hostname but with ws:// protocol + wsUrl = `ws://${window.location.hostname}:5005`; + } + + console.log("WebSocket URL:", wsUrl); + this.ws = new WebSocket(`${wsUrl}/api/vision/ws/stream`); + + this.ws.onopen = () => { + console.log("WebSocket connected"); + this.reconnectAttempts = 0; + + // Send configuration + this.ws?.send( + JSON.stringify({ + action: "start", + config: { + fps: config.fps || 30, + quality: config.quality || 85, + resolution: config.resolution || "720p", + region_x: config.region_x || 0, + region_y: config.region_y || 0, + region_width: config.region_width || 640, + region_height: config.region_height || 480, + }, + }), + ); + + this.onConnect?.(); + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === "frame") { + this.onFrame(data); + } else if (data.type === "error") { + console.error("Stream error:", data.message); + this.onError?.(data.message); + } else if (data.type === "config") { + console.log("Stream config:", data.data); + } + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.onError?.(error); + }; + + this.ws.onclose = () => { + console.log("WebSocket disconnected"); + this.onDisconnect?.(); + + // Attempt reconnection + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + console.log(`Reconnecting... (attempt ${this.reconnectAttempts})`); + setTimeout(() => this.connect(config), this.reconnectDelay * this.reconnectAttempts); + } + }; + } + + disconnect() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + updateConfig(config: any) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + JSON.stringify({ + action: "update", + config, + }), + ); + } + } +} + +// Export a function to manually check backend connection +export async function checkBackendConnection(): Promise { + try { + await systemAPI.getHealth(); + return true; + } catch (error) { + console.error("Backend connection check failed:", error); + return false; + } +} + +// Export the current API base URL for debugging +export function getCurrentApiUrl(): string { + return API_BASE_URL; +} + +export default apiClient; diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts new file mode 100644 index 0000000..7db9ca6 --- /dev/null +++ b/frontend/src/api/system.ts @@ -0,0 +1,123 @@ +import { systemAPI } from './client' + +export interface SystemHealth { + status: string + timestamp: string + uptime_seconds: number + services: { + api: string + gspro: string + mdns: string + } +} + +export interface GSProStatus { + running: boolean + window_title: string + process: { + pid: number + name: string + cpu_percent: number + memory_mb: number + } | null + auto_focus: boolean + key_delay: number +} + +let connectionCheckPromise: Promise | null = null + +export async function checkBackendConnection(): Promise { + // Prevent multiple simultaneous connection checks + if (connectionCheckPromise) { + return connectionCheckPromise + } + + connectionCheckPromise = systemAPI.getHealth() + .then(() => true) + .catch(() => false) + .finally(() => { + connectionCheckPromise = null + }) + + return connectionCheckPromise +} + +export async function checkGSProStatus(): Promise { + try { + const status = await systemAPI.getGSProStatus() + return status + } catch (error) { + console.error('Failed to get GSPro status:', error) + return null + } +} + +export async function getSystemHealth(): Promise { + try { + const health = await systemAPI.getHealth() + return health + } catch (error) { + console.error('Failed to get system health:', error) + return null + } +} + +export async function findGSProWindows(): Promise { + try { + const result = await systemAPI.findGSProWindow() + return result.windows || [] + } catch (error) { + console.error('Failed to find GSPro windows:', error) + return [] + } +} + +export async function runSystemDiagnostics(): Promise { + try { + const diagnostics = await systemAPI.runDiagnostics() + return diagnostics + } catch (error) { + console.error('Failed to run diagnostics:', error) + return null + } +} + +export function formatUptime(seconds: number): string { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + + if (hours > 0) { + return `${hours}h ${minutes}m` + } else if (minutes > 0) { + return `${minutes}m ${secs}s` + } else { + return `${secs}s` + } +} + +export function getConnectionStatusColor(status: string): string { + switch (status) { + case 'connected': + return 'text-green-500' + case 'disconnected': + return 'text-red-500' + case 'connecting': + return 'text-yellow-500' + default: + return 'text-gray-500' + } +} + +export function getConnectionStatusIcon(status: string): string { + switch (status) { + case 'connected': + return '●' + case 'disconnected': + return '○' + case 'connecting': + return '◐' + default: + return '◯' + } +} diff --git a/frontend/src/components/AimPad.tsx b/frontend/src/components/AimPad.tsx new file mode 100644 index 0000000..cc164a5 --- /dev/null +++ b/frontend/src/components/AimPad.tsx @@ -0,0 +1,281 @@ +import React, { useState, useCallback, useEffect } from 'react' +import { actionsAPI } from '../api/client' +import { useStore } from '../stores/appStore' +import toast from 'react-hot-toast' + +interface AimPadProps { + size?: 'small' | 'medium' | 'large' +} + +const AimPad: React.FC = ({ size = 'large' }) => { + const { settings, setAimDirection } = useStore() + const [activeDirection, setActiveDirection] = useState(null) + const [isHolding, setIsHolding] = useState(false) + + // Size configurations + const sizeClasses = { + small: 'w-32 h-32', + medium: 'w-48 h-48', + large: 'w-64 h-64', + } + + const buttonSizeClasses = { + small: 'text-xl', + medium: 'text-2xl', + large: 'text-3xl', + } + + // Handle key press/release + const handleKeyAction = useCallback(async (key: string, action: 'down' | 'up') => { + try { + if (action === 'down') { + await actionsAPI.sendKeyDown(key) + setIsHolding(true) + + // Haptic feedback on mobile + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + } else { + await actionsAPI.sendKeyUp(key) + setIsHolding(false) + } + } catch (error) { + console.error(`Failed to send key ${action}:`, error) + if (action === 'down') { + toast.error('Failed to send command') + } + } + }, [settings.hapticFeedback]) + + // Handle single press + const handlePress = useCallback(async (key: string, direction?: string) => { + try { + await actionsAPI.sendKey(key) + + if (direction) { + // Flash the button + setActiveDirection(direction) + setTimeout(() => setActiveDirection(null), 150) + + // Update aim direction in store + if (direction === 'up') setAimDirection({ x: 0, y: 1 }) + else if (direction === 'down') setAimDirection({ x: 0, y: -1 }) + else if (direction === 'left') setAimDirection({ x: -1, y: 0 }) + else if (direction === 'right') setAimDirection({ x: 1, y: 0 }) + else if (direction === 'center') setAimDirection({ x: 0, y: 0 }) + } + + // Haptic feedback + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + } catch (error) { + console.error('Failed to send key:', error) + } + }, [settings.hapticFeedback, setAimDirection]) + + // Touch handlers for hold functionality + const handleTouchStart = (key: string, direction: string) => { + setActiveDirection(direction) + handleKeyAction(key, 'down') + } + + const handleTouchEnd = (key: string) => { + setActiveDirection(null) + if (isHolding) { + handleKeyAction(key, 'up') + } + } + + // Mouse handlers for desktop + const handleMouseDown = (key: string, direction: string) => { + setActiveDirection(direction) + handleKeyAction(key, 'down') + } + + const handleMouseUp = (key: string) => { + setActiveDirection(null) + if (isHolding) { + handleKeyAction(key, 'up') + } + } + + const handleMouseLeave = (key: string) => { + if (isHolding) { + setActiveDirection(null) + handleKeyAction(key, 'up') + } + } + + // Cleanup on unmount + useEffect(() => { + return () => { + if (isHolding) { + // Clean up any held keys + ['up', 'down', 'left', 'right'].forEach(key => { + actionsAPI.sendKeyUp(key).catch(() => {}) + }) + } + } + }, [isHolding]) + + return ( +
+ {/* D-Pad Container */} +
+ {/* Background circle */} +
+ + {/* Center lines */} +
+
+ + {/* Up Button */} + + + {/* Down Button */} + + + {/* Left Button */} + + + {/* Right Button */} + + + {/* Center Reset Button */} + +
+ + {/* Label */} +
+ Aim Control +
+ + {/* Hold indicator */} + {isHolding && ( +
+ Holding... +
+ )} +
+ ) +} + +export default AimPad diff --git a/frontend/src/components/ClubIndicator.tsx b/frontend/src/components/ClubIndicator.tsx new file mode 100644 index 0000000..61e5409 --- /dev/null +++ b/frontend/src/components/ClubIndicator.tsx @@ -0,0 +1,168 @@ +import React from 'react' +import { useStore } from '../stores/appStore' +import { actionsAPI } from '../api/client' +import toast from 'react-hot-toast' + +interface ClubIndicatorProps { + compact?: boolean +} + +const ClubIndicator: React.FC = ({ compact = false }) => { + const { club, nextClub, previousClub, settings } = useStore() + + const handleClubChange = async (direction: 'up' | 'down') => { + try { + const key = direction === 'up' ? 'u' : 'k' + await actionsAPI.sendKey(key) + + if (direction === 'up') { + previousClub() + } else { + nextClub() + } + + // Haptic feedback + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + } catch (error) { + toast.error('Failed to change club') + } + } + + const getClubIcon = (clubName: string) => { + // Return appropriate icon based on club type + if (clubName.includes('Driver')) return '🏌️' + if (clubName.includes('Wood')) return '🪵' + if (clubName.includes('Hybrid')) return '⚡' + if (clubName.includes('Iron')) return '🔧' + if (clubName.includes('Wedge') || clubName.includes('W')) return '⛳' + if (clubName.includes('Putter')) return '🎯' + return '🏌️' + } + + const getClubDistance = (clubName: string) => { + // Approximate distances for each club + const distances: { [key: string]: string } = { + 'Driver': '250-280y', + '3 Wood': '215-240y', + '5 Wood': '200-220y', + '3 Hybrid': '190-210y', + '4 Iron': '180-200y', + '5 Iron': '170-190y', + '6 Iron': '160-180y', + '7 Iron': '150-170y', + '8 Iron': '140-160y', + '9 Iron': '130-150y', + 'PW': '120-140y', + 'GW': '100-120y', + 'SW': '80-100y', + 'LW': '60-80y', + 'Putter': 'Green', + } + return distances[clubName] || '---' + } + + if (compact) { + // Compact version for mobile + return ( +
+
+ + +
+
{club.current}
+
{getClubDistance(club.current)}
+
+ + +
+
+ ) + } + + // Full version for desktop/tablet + return ( +
+ {/* Header */} +
+

Club Selection

+
+ + {/* Current Club Display */} +
+
+
{getClubIcon(club.current)}
+
{club.current}
+
{getClubDistance(club.current)}
+
+ + {/* Club Change Buttons */} +
+ + + +
+ + {/* Club List Preview */} +
+
Club Bag
+
+ {[ + club.index > 0 ? `↑ ${['Driver', '3 Wood', '5 Wood', '3 Hybrid', '4 Iron', '5 Iron', '6 Iron', '7 Iron', '8 Iron', '9 Iron', 'PW', 'GW', 'SW', 'LW', 'Putter'][club.index - 1]}` : '', + `→ ${club.current}`, + club.index < 14 ? `↓ ${['Driver', '3 Wood', '5 Wood', '3 Hybrid', '4 Iron', '5 Iron', '6 Iron', '7 Iron', '8 Iron', '9 Iron', 'PW', 'GW', 'SW', 'LW', 'Putter'][club.index + 1]}` : '', + ].filter(Boolean).map((text, idx) => ( +
+ {text} +
+ ))} +
+
+
+
+ ) +} + +export default ClubIndicator diff --git a/frontend/src/components/ConnectionStatus.tsx b/frontend/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..1042d1d --- /dev/null +++ b/frontend/src/components/ConnectionStatus.tsx @@ -0,0 +1,125 @@ +import React, { useEffect, useState } from 'react' +import { useStore } from '../stores/appStore' +import { getSystemHealth, formatUptime } from '../api/system' + +const ConnectionStatus: React.FC = () => { + const { connectionStatus, gsproStatus } = useStore() + const [health, setHealth] = useState(null) + const [isVisible, setIsVisible] = useState(true) + + useEffect(() => { + const fetchHealth = async () => { + const healthData = await getSystemHealth() + if (healthData) { + setHealth(healthData) + } + } + + fetchHealth() + const interval = setInterval(fetchHealth, 10000) // Update every 10 seconds + + return () => clearInterval(interval) + }, []) + + useEffect(() => { + // Auto-hide the status bar after 5 seconds if everything is connected + if (connectionStatus === 'connected' && gsproStatus === 'running') { + const timer = setTimeout(() => setIsVisible(false), 5000) + return () => clearTimeout(timer) + } else { + setIsVisible(true) + } + }, [connectionStatus, gsproStatus]) + + const getStatusColor = () => { + if (connectionStatus !== 'connected') return 'bg-red-600' + if (gsproStatus !== 'running') return 'bg-yellow-600' + return 'bg-green-600' + } + + const getStatusText = () => { + if (connectionStatus !== 'connected') return 'Backend Disconnected' + if (gsproStatus !== 'running') return 'GSPro Not Running' + return 'Connected' + } + + if (!isVisible && connectionStatus === 'connected' && gsproStatus === 'running') { + // Show a minimal indicator when hidden + return ( +
setIsVisible(true)} + > +
+
+
+
+ ) + } + + return ( +
+
+
+
+
+ {/* Status indicator */} +
+
+ {getStatusText()} +
+ + {/* Backend status */} + {connectionStatus === 'connected' && ( + <> +
+ Backend: + localhost:5005 +
+ + {/* Uptime */} + {health && ( +
+ Uptime: + {formatUptime(health.uptime_seconds)} +
+ )} + + )} +
+ + {/* Actions */} +
+ {/* GSPro status */} + {connectionStatus === 'connected' && ( +
+ + + + + {gsproStatus === 'running' ? 'GSPro Ready' : 'GSPro Not Found'} +
+ )} + + {/* Close button for mobile */} + +
+
+
+
+
+ ) +} + +export default ConnectionStatus diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..852b5fd --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,106 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode +} + +interface State { + hasError: boolean + error: Error | null + errorInfo: ErrorInfo | null +} + +class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null, + errorInfo: null, + } + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error, errorInfo: null } + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo) + this.setState({ + error, + errorInfo, + }) + } + + private handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }) + window.location.reload() + } + + public render() { + if (this.state.hasError) { + return ( +
+
+
+
+ + + +
+
+

Something went wrong

+

An unexpected error occurred

+
+
+ + {this.state.error && ( +
+

{this.state.error.toString()}

+
+ )} + + {process.env.NODE_ENV === 'development' && this.state.errorInfo && ( +
+ + Show details + +
+                  {this.state.errorInfo.componentStack}
+                
+
+ )} + +
+ + +
+
+
+ ) + } + + return this.props.children + } +} + +export default ErrorBoundary diff --git a/frontend/src/components/MapPanel.tsx b/frontend/src/components/MapPanel.tsx new file mode 100644 index 0000000..aa903f0 --- /dev/null +++ b/frontend/src/components/MapPanel.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useRef, useState } from 'react' +import { useStore } from '../stores/appStore' +import { StreamingClient } from '../api/client' +import { actionsAPI } from '../api/client' +import toast from 'react-hot-toast' + +interface MapPanelProps { + compact?: boolean +} + +const MapPanel: React.FC = ({ compact = false }) => { + const { mapConfig, toggleMapExpanded, setMapStreaming, settings } = useStore() + const [isConnected, setIsConnected] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const canvasRef = useRef(null) + const streamClientRef = useRef(null) + const animationFrameRef = useRef() + + useEffect(() => { + // Initialize streaming client + streamClientRef.current = new StreamingClient( + (data) => { + // Handle incoming frame + if (data.data && canvasRef.current) { + const img = new Image() + img.onload = () => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + // Clear and draw new frame + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(img, 0, 0, canvas.width, canvas.height) + } + img.src = `data:image/jpeg;base64,${data.data}` + } + }, + (error) => { + console.error('Stream error:', error) + toast.error('Stream connection lost') + setIsConnected(false) + setMapStreaming(false) + }, + () => { + console.log('Stream connected') + setIsConnected(true) + setMapStreaming(true) + setIsLoading(false) + }, + () => { + console.log('Stream disconnected') + setIsConnected(false) + setMapStreaming(false) + } + ) + + return () => { + // Cleanup + streamClientRef.current?.disconnect() + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, [setMapStreaming]) + + const handleStartStream = () => { + setIsLoading(true) + const config = { + fps: settings.streamFPS, + quality: settings.streamQuality === 'high' ? 90 : settings.streamQuality === 'medium' ? 75 : 60, + resolution: settings.streamQuality === 'high' ? '1080p' : settings.streamQuality === 'medium' ? '720p' : '480p', + region_x: mapConfig.x, + region_y: mapConfig.y, + region_width: mapConfig.width, + region_height: mapConfig.height, + } + streamClientRef.current?.connect(config) + } + + const handleStopStream = () => { + streamClientRef.current?.disconnect() + } + + const handleToggleMap = async () => { + try { + await actionsAPI.sendKey('s') + toggleMapExpanded() + + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + } catch (error) { + toast.error('Failed to toggle map') + } + } + + const handleZoom = async (direction: 'in' | 'out') => { + try { + const key = direction === 'in' ? 'q' : 'w' + await actionsAPI.sendKey(key) + + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + } catch (error) { + toast.error('Failed to zoom map') + } + } + + if (compact) { + // Compact version for mobile + return ( +
+
+ Map + +
+ +
+ {!isConnected ? ( +
+ +
+ ) : ( + <> + + + + )} +
+
+ ) + } + + // Full version for desktop/tablet + return ( +
+ {/* Header */} +
+

Course Map

+
+ {isConnected && ( + <> + + + + )} + +
+
+ + {/* Map Display */} +
+ {!isConnected ? ( +
+
+ + + +

Map Stream

+

View the GSPro course map in real-time

+ +
+
+ ) : ( + <> + + + {/* Stream Controls Overlay */} +
+
+
+ Live +
+ +
+ + {/* Map Click Overlay (when expanded) */} + {mapConfig.expanded && ( +
+

Click on the map to set target location

+
+ )} + + )} +
+ + {/* Stream Info */} + {isConnected && !mapConfig.expanded && ( +
+
+ Quality: {settings.streamQuality} + FPS: {settings.streamFPS} + Resolution: {settings.streamQuality === 'high' ? '1080p' : settings.streamQuality === 'medium' ? '720p' : '480p'} +
+
+ )} +
+ ) +} + +export default MapPanel diff --git a/frontend/src/components/QuickActions.tsx b/frontend/src/components/QuickActions.tsx new file mode 100644 index 0000000..675e7dc --- /dev/null +++ b/frontend/src/components/QuickActions.tsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react' +import { actionsAPI } from '../api/client' +import toast from 'react-hot-toast' +import { useStore } from '../stores/appStore' + +interface QuickActionsProps { + mobile?: boolean +} + +const QuickActions: React.FC = ({ mobile = false }) => { + const [isOpen, setIsOpen] = useState(false) + const { settings } = useStore() + + const actions = [ + { + id: 'scorecard', + label: 'Scorecard', + icon: '📊', + key: 't', + description: 'Toggle scorecard', + }, + { + id: 'rangefinder', + label: 'Range Finder', + icon: '🎯', + key: 'r', + description: 'Launch range finder', + }, + { + id: 'heatmap', + label: 'Heat Map', + icon: '🗺️', + key: 'y', + description: 'Toggle heat map', + }, + { + id: 'flyover', + label: 'Flyover', + icon: '🚁', + key: 'o', + description: 'Hole preview', + }, + { + id: 'pin', + label: 'Pin Indicator', + icon: '🚩', + key: 'p', + description: 'Show/hide pin', + }, + { + id: 'freelook', + label: 'Free Look', + icon: '👀', + key: 'f5', + description: 'Unlock camera', + }, + { + id: 'aimpoint', + label: 'Aim Point', + icon: '🎪', + key: 'f3', + description: 'Toggle aim point', + }, + { + id: 'tracerclear', + label: 'Clear Tracer', + icon: '💨', + key: 'f1', + description: 'Clear ball tracer', + }, + ] + + const handleAction = async (action: typeof actions[0]) => { + try { + await actionsAPI.sendKey(action.key) + toast.success(action.description) + setIsOpen(false) + + // Haptic feedback + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + } catch (error) { + toast.error(`Failed to execute ${action.label}`) + } + } + + const handleToggle = () => { + setIsOpen(!isOpen) + + // Haptic feedback + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + } + + if (mobile) { + // Mobile version - bottom sheet style + return ( + <> + {/* Floating Action Button */} + + + {/* Bottom Sheet */} +
+
+
+

Quick Actions

+
+ {actions.map((action) => ( + + ))} +
+
+
+ + {/* Backdrop */} + {isOpen && ( +
setIsOpen(false)} + /> + )} + + ) + } + + // Desktop version - floating menu + return ( +
+ {/* Action Menu */} +
+
+
+ {actions.map((action) => ( + + ))} +
+
+
+ + {/* Floating Action Button */} + +
+ ) +} + +export default QuickActions diff --git a/frontend/src/components/ShotOptions.tsx b/frontend/src/components/ShotOptions.tsx new file mode 100644 index 0000000..f4bfa2e --- /dev/null +++ b/frontend/src/components/ShotOptions.tsx @@ -0,0 +1,130 @@ +import React from 'react' +import { useStore } from '../stores/appStore' +import { actionsAPI } from '../api/client' +import toast from 'react-hot-toast' + +interface ShotOptionsProps { + compact?: boolean +} + +const ShotOptions: React.FC = ({ compact = false }) => { + const { shotMode, setShotMode, settings } = useStore() + + const shotTypes = [ + { id: 'normal', label: 'Normal', icon: '⚫', key: null }, + { id: 'punch', label: 'Punch', icon: '👊', key: "'" }, + { id: 'flop', label: 'Flop', icon: '🎯', key: "'" }, + { id: 'chip', label: 'Chip', icon: '⛳', key: "'" }, + ] + + const handleShotChange = async (mode: 'normal' | 'punch' | 'flop' | 'chip') => { + try { + // Send key if needed (shot options typically cycle with apostrophe key) + if (mode !== 'normal' && mode !== shotMode) { + await actionsAPI.sendKey("'") + } + + setShotMode(mode) + + // Haptic feedback + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + + toast.success(`Shot mode: ${mode}`) + } catch (error) { + toast.error('Failed to change shot mode') + } + } + + if (compact) { + // Compact horizontal layout for mobile + return ( +
+
Shot Type
+
+ {shotTypes.map((shot) => ( + + ))} +
+
+ ) + } + + // Full version for desktop/tablet + return ( +
+

Shot Options

+ +
+ {shotTypes.map((shot) => ( + + ))} +
+ + {/* Additional shot controls */} +
+
+ + + +
+
+
+ ) +} + +export default ShotOptions diff --git a/frontend/src/components/StatBar.tsx b/frontend/src/components/StatBar.tsx new file mode 100644 index 0000000..179df82 --- /dev/null +++ b/frontend/src/components/StatBar.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from 'react' +import { systemAPI } from '../api/client' +import { useStore } from '../stores/appStore' + +interface StatBarProps { + compact?: boolean +} + +interface Stats { + windSpeed: number + windDirection: string + distance: number + elevation: number + lie: string +} + +const StatBar: React.FC = ({ compact = false }) => { + const { club, shotMode } = useStore() + const [stats, setStats] = useState({ + windSpeed: 0, + windDirection: 'N', + distance: 0, + elevation: 0, + lie: 'Fairway', + }) + + useEffect(() => { + // In a real implementation, these would come from the backend + // For now, using placeholder values + setStats({ + windSpeed: Math.floor(Math.random() * 20), + windDirection: ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'][Math.floor(Math.random() * 8)], + distance: 150, + elevation: Math.floor(Math.random() * 20) - 10, + lie: 'Fairway', + }) + }, [club]) + + const getWindArrow = (direction: string) => { + const arrows: { [key: string]: string } = { + 'N': '↑', + 'NE': '↗', + 'E': '→', + 'SE': '↘', + 'S': '↓', + 'SW': '↙', + 'W': '←', + 'NW': '↖', + } + return arrows[direction] || '↑' + } + + const getLieColor = (lie: string) => { + switch (lie.toLowerCase()) { + case 'fairway': + return 'text-green-400' + case 'rough': + return 'text-yellow-400' + case 'sand': + return 'text-orange-400' + case 'water': + return 'text-blue-400' + default: + return 'text-gray-400' + } + } + + if (compact) { + // Compact version for mobile + return ( +
+
+
+ Wind: + {stats.windSpeed}mph + {getWindArrow(stats.windDirection)} +
+ +
+ Dist: + {stats.distance}y +
+ +
+ Elev: + 0 ? 'text-red-400' : stats.elevation < 0 ? 'text-blue-400' : 'text-gray-400'}`}> + {stats.elevation > 0 ? '+' : ''}{stats.elevation}ft + +
+ +
+ Lie: + {stats.lie} +
+
+
+ ) + } + + // Full version for desktop/tablet + return ( +
+
+ {/* Wind */} +
+
Wind
+
+ {getWindArrow(stats.windDirection)} +
+
{stats.windSpeed}
+
mph
+
+
+
+ + {/* Distance to Pin */} +
+
To Pin
+
{stats.distance}
+
yards
+
+ + {/* Elevation */} +
+
Elevation
+
0 ? 'text-red-400' : stats.elevation < 0 ? 'text-blue-400' : 'text-gray-400'}`}> + {stats.elevation > 0 ? '+' : ''}{stats.elevation} +
+
feet
+
+ + {/* Lie */} +
+
Lie
+
{stats.lie}
+
{shotMode} shot
+
+ + {/* Club */} +
+
Club
+
{club.current}
+
Selected
+
+
+ + {/* Additional info bar */} +
+
+
+ Hole: 1 • Par 4 + Stroke: 1 +
+
+ Round Time: 0:00 + Score: E +
+
+
+
+ ) +} + +export default StatBar diff --git a/frontend/src/components/TeeControls.tsx b/frontend/src/components/TeeControls.tsx new file mode 100644 index 0000000..7c82937 --- /dev/null +++ b/frontend/src/components/TeeControls.tsx @@ -0,0 +1,110 @@ +import React from 'react' +import { actionsAPI } from '../api/client' +import { useStore } from '../stores/appStore' +import toast from 'react-hot-toast' + +interface TeeControlsProps { + compact?: boolean +} + +const TeeControls: React.FC = ({ compact = false }) => { + const { settings } = useStore() + + const handleTeeMove = async (direction: 'left' | 'right') => { + try { + const key = direction === 'left' ? 'c' : 'v' + await actionsAPI.sendKey(key) + + // Haptic feedback + if (settings.hapticFeedback && 'vibrate' in navigator) { + navigator.vibrate(10) + } + + toast.success(`Tee moved ${direction}`) + } catch (error) { + toast.error('Failed to move tee') + } + } + + if (compact) { + // Compact version for mobile + return ( +
+
Tee Box
+
+ + +
+
+
+ + +
+
+ ) + } + + // Full version for desktop/tablet + return ( +
+

Tee Position

+ +
+ + +
+
+ + + +
+
+
+ + +
+ +
+

Adjust your starting position on the tee box

+
+
+ ) +} + +export default TeeControls diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..9644434 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,361 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom global styles */ +@layer base { + /* Set default font and colors */ + html { + @apply font-sans antialiased; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Custom scrollbar styles */ + ::-webkit-scrollbar { + @apply w-2 h-2; + } + + ::-webkit-scrollbar-track { + @apply bg-gray-800 rounded-full; + } + + ::-webkit-scrollbar-thumb { + @apply bg-gray-600 rounded-full hover:bg-gray-500; + } + + /* Firefox scrollbar */ + * { + scrollbar-width: thin; + scrollbar-color: #4b5563 #1f2937; + } + + /* Viewport height fix for mobile */ + .h-screen-safe { + height: 100vh; + height: calc(var(--vh, 1vh) * 100); + } + + /* Prevent text selection on interactive elements */ + button, + .btn, + .interactive { + @apply select-none; + -webkit-tap-highlight-color: transparent; + } + + /* Focus styles */ + .focus-ring { + @apply focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-gray-900; + } +} + +@layer components { + /* Button base styles */ + .btn { + @apply inline-flex items-center justify-center px-4 py-2 font-medium rounded-lg transition-all duration-200 focus-ring disabled:opacity-50 disabled:cursor-not-allowed; + } + + /* Button variants */ + .btn-primary { + @apply btn bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800; + } + + .btn-secondary { + @apply btn bg-secondary-600 text-white hover:bg-secondary-700 active:bg-secondary-800; + } + + .btn-success { + @apply btn bg-success-600 text-white hover:bg-success-700 active:bg-success-800; + } + + .btn-danger { + @apply btn bg-error-600 text-white hover:bg-error-700 active:bg-error-800; + } + + .btn-ghost { + @apply btn bg-transparent hover:bg-gray-800 active:bg-gray-700; + } + + .btn-outline { + @apply btn border-2 border-gray-600 hover:bg-gray-800 active:bg-gray-700; + } + + /* Icon button */ + .btn-icon { + @apply inline-flex items-center justify-center p-2 rounded-lg transition-all duration-200 focus-ring hover:bg-gray-800 active:bg-gray-700; + } + + /* Card component */ + .card { + @apply bg-gray-800 rounded-xl border border-gray-700 shadow-lg; + } + + .card-body { + @apply p-4; + } + + /* Input styles */ + .input { + @apply block w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500; + } + + /* Label styles */ + .label { + @apply block text-sm font-medium text-gray-300 mb-1; + } + + /* Directional pad button */ + .dpad-btn { + @apply flex items-center justify-center bg-gray-700 hover:bg-gray-600 active:bg-primary-600 transition-all duration-150 select-none cursor-pointer; + } + + /* Status indicator */ + .status-dot { + @apply inline-block w-2 h-2 rounded-full; + } + + .status-dot.online { + @apply bg-success-500 animate-pulse; + } + + .status-dot.offline { + @apply bg-gray-500; + } + + .status-dot.error { + @apply bg-error-500; + } + + /* Golf-specific styles */ + .golf-green { + @apply bg-gradient-to-br from-green-700 to-green-800; + } + + .golf-fairway { + @apply bg-gradient-to-br from-green-600 to-green-700; + } + + .golf-sand { + @apply bg-gradient-to-br from-yellow-600 to-yellow-700; + } + + .golf-water { + @apply bg-gradient-to-br from-blue-600 to-blue-700; + } + + /* Aim pad grid */ + .aim-grid { + @apply grid grid-cols-3 gap-1 w-48 h-48; + } + + /* Map panel */ + .map-panel { + @apply relative bg-black rounded-lg overflow-hidden; + } + + .map-panel.expanded { + @apply fixed inset-4 z-50 md:inset-8 lg:inset-16; + } + + /* Stat display */ + .stat-item { + @apply flex flex-col items-center p-2 bg-gray-800 rounded-lg; + } + + .stat-value { + @apply text-xl font-bold text-primary-400; + } + + .stat-label { + @apply text-xs text-gray-400 uppercase tracking-wider; + } + + /* Toggle switch */ + .toggle { + @apply relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-gray-900; + } + + .toggle.checked { + @apply bg-primary-600; + } + + .toggle:not(.checked) { + @apply bg-gray-700; + } + + .toggle-thumb { + @apply inline-block h-4 w-4 transform rounded-full bg-white transition-transform; + } + + .toggle.checked .toggle-thumb { + @apply translate-x-6; + } + + .toggle:not(.checked) .toggle-thumb { + @apply translate-x-1; + } +} + +@layer utilities { + /* Touch action utilities */ + .touch-none { + touch-action: none; + } + + .touch-pan-x { + touch-action: pan-x; + } + + .touch-pan-y { + touch-action: pan-y; + } + + /* Prevent double-tap zoom on mobile */ + .no-tap-zoom { + touch-action: manipulation; + } + + /* Glass morphism effect */ + .glass { + @apply bg-gray-900 bg-opacity-60 backdrop-blur-lg; + } + + /* Gradient text */ + .gradient-text { + @apply bg-gradient-to-r from-primary-400 to-success-400 bg-clip-text text-transparent; + } + + /* Loading animation */ + @keyframes shimmer { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } + } + + .shimmer { + animation: shimmer 2s linear infinite; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0.1) 50%, + rgba(255, 255, 255, 0) 100% + ); + background-size: 1000px 100%; + } + + /* Fade animations */ + .fade-in { + animation: fadeIn 0.3s ease-in-out; + } + + .fade-out { + animation: fadeOut 0.3s ease-in-out; + } + + /* Slide animations */ + .slide-in-left { + animation: slideInLeft 0.3s ease-out; + } + + .slide-in-right { + animation: slideInRight 0.3s ease-out; + } + + .slide-in-up { + animation: slideInUp 0.3s ease-out; + } + + .slide-in-down { + animation: slideInDown 0.3s ease-out; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + + @keyframes slideInLeft { + from { + transform: translateX(-100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + + @keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } + } + + @keyframes slideInUp { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + @keyframes slideInDown { + from { + transform: translateY(-100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + /* Responsive text utilities */ + .text-responsive { + @apply text-sm sm:text-base lg:text-lg; + } + + .heading-responsive { + @apply text-2xl sm:text-3xl lg:text-4xl; + } + + /* Custom grid utilities */ + .grid-center { + @apply grid place-items-center; + } + + /* Aspect ratio utilities */ + .aspect-map { + aspect-ratio: 4/3; + } + + /* Overlay utilities */ + .overlay { + @apply fixed inset-0 bg-black bg-opacity-50 z-40; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..5617cad --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +// Create root element +const rootElement = document.getElementById('root') +if (!rootElement) { + throw new Error('Failed to find the root element') +} + +// Create React root +const root = ReactDOM.createRoot(rootElement) + +// Render the app +root.render( + + + +) + +// Signal that the app is ready (removes loading screen) +window.dispatchEvent(new Event('app-ready')) diff --git a/frontend/src/pages/DynamicGolfUI.tsx b/frontend/src/pages/DynamicGolfUI.tsx new file mode 100644 index 0000000..cbee9be --- /dev/null +++ b/frontend/src/pages/DynamicGolfUI.tsx @@ -0,0 +1,202 @@ +import React, { useEffect, useState } from 'react' +import { useStore } from '../stores/appStore' +import AimPad from '../components/AimPad' +import ClubIndicator from '../components/ClubIndicator' +import MapPanel from '../components/MapPanel' +import ShotOptions from '../components/ShotOptions' +import TeeControls from '../components/TeeControls' +import StatBar from '../components/StatBar' +import QuickActions from '../components/QuickActions' +import { actionsAPI, systemAPI } from '../api/client' +import toast from 'react-hot-toast' + +const DynamicGolfUI: React.FC = () => { + const { + club, + mapConfig, + shotMode, + mulliganActive, + setGSProStatus, + toggleMulligan, + toggleMapExpanded, + } = useStore() + + const [isLandscape, setIsLandscape] = useState( + window.innerWidth > window.innerHeight + ) + + useEffect(() => { + // Check GSPro status + const checkGSPro = async () => { + try { + const status = await systemAPI.getGSProStatus() + setGSProStatus(status.running ? 'running' : 'not_running') + } catch (error) { + setGSProStatus('unknown') + } + } + + checkGSPro() + const interval = setInterval(checkGSPro, 5000) + + return () => clearInterval(interval) + }, [setGSProStatus]) + + useEffect(() => { + // Handle orientation change + const handleResize = () => { + setIsLandscape(window.innerWidth > window.innerHeight) + } + + window.addEventListener('resize', handleResize) + window.addEventListener('orientationchange', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('orientationchange', handleResize) + } + }, []) + + const handleMulligan = async () => { + try { + await actionsAPI.sendKey('ctrl+m') + toggleMulligan() + toast.success(mulliganActive ? 'Mulligan disabled' : 'Mulligan enabled') + } catch (error) { + toast.error('Failed to toggle mulligan') + } + } + + const handleReset = async () => { + try { + await actionsAPI.sendKey('a') + toast.success('Reset') + } catch (error) { + toast.error('Failed to reset') + } + } + + // Render different layouts based on orientation + if (isLandscape) { + // Landscape layout (tablets, desktop) + return ( +
+
+ {/* Header */} +
+
+

GSPro Remote

+ v0.1.0 +
+
+ + +
+
+ + {/* Main content */} +
+ {/* Left panel - Club */} +
+ +
+ + {/* Center - Aim Pad */} +
+ +
+ +
+
+ + {/* Right panel - Map */} +
+ +
+ +
+
+
+ + {/* Bottom bar */} +
+ +
+
+ + {/* Quick actions floating button */} + +
+ ) + } else { + // Portrait layout (phones) + return ( +
+
+ {/* Header - Compact */} +
+

GSPro Remote

+ +
+ + {/* Main content area */} +
+ {/* Top row - Club and Map */} +
+
+ +
+
+ +
+
+ + {/* Center - Aim Pad */} +
+ +
+ + {/* Bottom controls */} +
+ + +
+
+ + {/* Bottom stats */} +
+ +
+
+ + {/* Quick actions for mobile */} + +
+ ) + } +} + +export default DynamicGolfUI diff --git a/frontend/src/stores/appStore.ts b/frontend/src/stores/appStore.ts new file mode 100644 index 0000000..b74bd08 --- /dev/null +++ b/frontend/src/stores/appStore.ts @@ -0,0 +1,229 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +export interface ClubInfo { + current: string + index: number +} + +export interface MapConfig { + expanded: boolean + zoom: number + x: number + y: number + width: number + height: number +} + +export interface AppSettings { + hapticFeedback: boolean + soundEffects: boolean + streamQuality: 'low' | 'medium' | 'high' + streamFPS: number + showStats: boolean + compactMode: boolean +} + +export interface AppState { + // Connection status + isConnected: boolean + connectionStatus: 'connected' | 'disconnected' | 'connecting' + gsproStatus: 'running' | 'not_running' | 'unknown' + + // UI state + currentView: 'main' | 'settings' | 'advanced' + isLoading: boolean + + // Golf state + club: ClubInfo + aimDirection: { x: number; y: number } + mulliganActive: boolean + shotMode: 'normal' | 'punch' | 'flop' | 'chip' + + // Map state + mapConfig: MapConfig + isMapStreaming: boolean + + // Settings + settings: AppSettings + + // Actions + setConnectionStatus: (connected: boolean) => void + setGSProStatus: (status: 'running' | 'not_running' | 'unknown') => void + setCurrentView: (view: 'main' | 'settings' | 'advanced') => void + setLoading: (loading: boolean) => void + + // Golf actions + setClub: (club: ClubInfo) => void + nextClub: () => void + previousClub: () => void + setAimDirection: (direction: { x: number; y: number }) => void + toggleMulligan: () => void + setShotMode: (mode: 'normal' | 'punch' | 'flop' | 'chip') => void + + // Map actions + toggleMapExpanded: () => void + setMapConfig: (config: Partial) => void + setMapStreaming: (streaming: boolean) => void + + // Settings actions + updateSettings: (settings: Partial) => void + resetSettings: () => void +} + +const CLUBS = [ + 'Driver', + '3 Wood', + '5 Wood', + '3 Hybrid', + '4 Iron', + '5 Iron', + '6 Iron', + '7 Iron', + '8 Iron', + '9 Iron', + 'PW', + 'GW', + 'SW', + 'LW', + 'Putter' +] + +const DEFAULT_SETTINGS: AppSettings = { + hapticFeedback: true, + soundEffects: false, + streamQuality: 'medium', + streamFPS: 30, + showStats: true, + compactMode: false, +} + +const DEFAULT_MAP_CONFIG: MapConfig = { + expanded: false, + zoom: 1, + x: 0, + y: 0, + width: 640, + height: 480, +} + +export const useStore = create()( + devtools( + persist( + (set, get) => ({ + // Initial state + isConnected: false, + connectionStatus: 'disconnected', + gsproStatus: 'unknown', + currentView: 'main', + isLoading: false, + + club: { + current: 'Driver', + index: 0, + }, + aimDirection: { x: 0, y: 0 }, + mulliganActive: false, + shotMode: 'normal', + + mapConfig: DEFAULT_MAP_CONFIG, + isMapStreaming: false, + + settings: DEFAULT_SETTINGS, + + // Connection actions + setConnectionStatus: (connected) => + set({ + isConnected: connected, + connectionStatus: connected ? 'connected' : 'disconnected', + }), + + setGSProStatus: (status) => set({ gsproStatus: status }), + + // UI actions + setCurrentView: (view) => set({ currentView: view }), + setLoading: (loading) => set({ isLoading: loading }), + + // Golf actions + setClub: (club) => set({ club }), + + nextClub: () => { + const state = get() + const nextIndex = (state.club.index + 1) % CLUBS.length + set({ + club: { + current: CLUBS[nextIndex], + index: nextIndex, + }, + }) + }, + + previousClub: () => { + const state = get() + const prevIndex = state.club.index === 0 ? CLUBS.length - 1 : state.club.index - 1 + set({ + club: { + current: CLUBS[prevIndex], + index: prevIndex, + }, + }) + }, + + setAimDirection: (direction) => set({ aimDirection: direction }), + + toggleMulligan: () => + set((state) => ({ mulliganActive: !state.mulliganActive })), + + setShotMode: (mode) => set({ shotMode: mode }), + + // Map actions + toggleMapExpanded: () => + set((state) => ({ + mapConfig: { + ...state.mapConfig, + expanded: !state.mapConfig.expanded, + }, + })), + + setMapConfig: (config) => + set((state) => ({ + mapConfig: { + ...state.mapConfig, + ...config, + }, + })), + + setMapStreaming: (streaming) => set({ isMapStreaming: streaming }), + + // Settings actions + updateSettings: (settings) => + set((state) => ({ + settings: { + ...state.settings, + ...settings, + }, + })), + + resetSettings: () => set({ settings: DEFAULT_SETTINGS }), + }), + { + name: 'gspro-remote-store', + partialize: (state) => ({ + settings: state.settings, + mapConfig: state.mapConfig, + }), + } + ) + ) +) + +// Selectors +export const selectIsConnected = (state: AppState) => state.isConnected +export const selectConnectionStatus = (state: AppState) => state.connectionStatus +export const selectGSProStatus = (state: AppState) => state.gsproStatus +export const selectCurrentView = (state: AppState) => state.currentView +export const selectClub = (state: AppState) => state.club +export const selectMapConfig = (state: AppState) => state.mapConfig +export const selectSettings = (state: AppState) => state.settings +export const selectIsMapExpanded = (state: AppState) => state.mapConfig.expanded +export const selectIsMapStreaming = (state: AppState) => state.isMapStreaming diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..18fdb46 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,131 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + }, + secondary: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 900: '#0f172a', + }, + success: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + }, + warning: { + 50: '#fefce8', + 100: '#fef9c3', + 200: '#fef08a', + 300: '#fde047', + 400: '#facc15', + 500: '#eab308', + 600: '#ca8a04', + 700: '#a16207', + 800: '#854d0e', + 900: '#713f12', + }, + error: { + 50: '#fef2f2', + 100: '#fee2e2', + 200: '#fecaca', + 300: '#fca5a5', + 400: '#f87171', + 500: '#ef4444', + 600: '#dc2626', + 700: '#b91c1c', + 800: '#991b1b', + 900: '#7f1d1d', + }, + golf: { + green: '#2D5016', + fairway: '#355E3B', + sand: '#C2B280', + water: '#4682B4', + rough: '#3A5F0B', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + mono: ['JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', 'monospace'], + }, + animation: { + 'fade-in': 'fadeIn 0.3s ease-in-out', + 'fade-out': 'fadeOut 0.3s ease-in-out', + 'slide-in': 'slideIn 0.3s ease-out', + 'slide-out': 'slideOut 0.3s ease-out', + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + 'bounce-slow': 'bounce 2s infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + fadeOut: { + '0%': { opacity: '1' }, + '100%': { opacity: '0' }, + }, + slideIn: { + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(0)' }, + }, + slideOut: { + '0%': { transform: 'translateX(0)' }, + '100%': { transform: 'translateX(100%)' }, + }, + }, + boxShadow: { + 'golf': '0 4px 6px -1px rgba(45, 80, 22, 0.1), 0 2px 4px -1px rgba(45, 80, 22, 0.06)', + 'golf-lg': '0 10px 15px -3px rgba(45, 80, 22, 0.1), 0 4px 6px -2px rgba(45, 80, 22, 0.05)', + }, + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + 'golf-pattern': "url('data:image/svg+xml,%3Csvg width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" xmlns=\"http://www.w3.org/2000/svg\"%3E%3Cg fill=\"%232D5016\" fill-opacity=\"0.03\"%3E%3Cpath d=\"M0 40L40 0H20L0 20M40 40V20L20 40\"%3E%3C/path%3E%3C/g%3E%3C/svg%3E')", + }, + spacing: { + '18': '4.5rem', + '88': '22rem', + '120': '30rem', + '128': '32rem', + '144': '36rem', + }, + screens: { + 'xs': '480px', + '3xl': '1920px', + }, + }, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f8c023b --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + /* Path aliases */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@components/*": ["src/components/*"], + "@pages/*": ["src/pages/*"], + "@hooks/*": ["src/hooks/*"], + "@utils/*": ["src/utils/*"], + "@api/*": ["src/api/*"], + "@stores/*": ["src/stores/*"], + "@types/*": ["src/types/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9098ec9 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,47 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@components': path.resolve(__dirname, './src/components'), + '@pages': path.resolve(__dirname, './src/pages'), + '@hooks': path.resolve(__dirname, './src/hooks'), + '@utils': path.resolve(__dirname, './src/utils'), + '@api': path.resolve(__dirname, './src/api'), + '@stores': path.resolve(__dirname, './src/stores'), + '@types': path.resolve(__dirname, './src/types'), + }, + }, + server: { + port: 5173, + host: true, + proxy: { + '/api': { + target: 'http://localhost:5005', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:5005', + ws: true, + }, + }, + }, + build: { + outDir: '../backend/ui', + emptyOutDir: true, + sourcemap: true, + rollupOptions: { + output: { + manualChunks: { + 'react-vendor': ['react', 'react-dom'], + 'ui-vendor': ['react-icons', 'clsx', 'react-hot-toast'], + }, + }, + }, + }, +}) diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 0000000..26e9ef3 --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,193 @@ +# GSPro Remote Development Script +# Starts both backend and frontend servers for development + +Write-Host "GSPro Remote Development Environment" -ForegroundColor Green +Write-Host "=====================================" -ForegroundColor Green +Write-Host "" + +# Check if Python is installed +$pythonVersion = python --version 2>$null +if (-not $pythonVersion) { + Write-Host "ERROR: Python is not installed or not in PATH" -ForegroundColor Red + Write-Host "Please install Python 3.11+ from https://www.python.org/" -ForegroundColor Yellow + exit 1 +} +Write-Host "✓ Python found: $pythonVersion" -ForegroundColor Green + +# Check if Node.js is installed +$nodeVersion = node --version 2>$null +if (-not $nodeVersion) { + Write-Host "ERROR: Node.js is not installed or not in PATH" -ForegroundColor Red + Write-Host "Please install Node.js 20+ from https://nodejs.org/" -ForegroundColor Yellow + exit 1 +} +Write-Host "✓ Node.js found: $nodeVersion" -ForegroundColor Green + +# Check if npm is installed +$npmVersion = npm --version 2>$null +if (-not $npmVersion) { + Write-Host "ERROR: npm is not installed or not in PATH" -ForegroundColor Red + exit 1 +} +Write-Host "✓ npm found: $npmVersion" -ForegroundColor Green +Write-Host "" + +# Get the script directory (project root) +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$projectRoot = Split-Path -Parent $scriptDir +$backendDir = Join-Path $projectRoot "backend" +$frontendDir = Join-Path $projectRoot "frontend" + +# Function to check if a port is in use +function Test-Port { + param($port) + $tcpClient = New-Object System.Net.Sockets.TcpClient + try { + $tcpClient.Connect("localhost", $port) + $tcpClient.Close() + return $true + } catch { + return $false + } +} + +# Check if backend port is already in use +if (Test-Port 5005) { + Write-Host "WARNING: Port 5005 is already in use. Backend server might already be running." -ForegroundColor Yellow + Write-Host "Do you want to continue anyway? (Y/N): " -NoNewline + $response = Read-Host + if ($response -ne "Y" -and $response -ne "y") { + exit 0 + } +} + +# Check if frontend port is already in use +if (Test-Port 5173) { + Write-Host "WARNING: Port 5173 is already in use. Frontend server might already be running." -ForegroundColor Yellow + Write-Host "Do you want to continue anyway? (Y/N): " -NoNewline + $response = Read-Host + if ($response -ne "Y" -and $response -ne "y") { + exit 0 + } +} + +# Setup backend +Write-Host "Setting up Backend..." -ForegroundColor Cyan +Set-Location $backendDir + +# Check if virtual environment exists +$venvPath = Join-Path $backendDir ".venv" +if (-not (Test-Path $venvPath)) { + Write-Host "Creating Python virtual environment..." -ForegroundColor Yellow + python -m venv .venv + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to create virtual environment" -ForegroundColor Red + exit 1 + } +} + +# Activate virtual environment and install dependencies +Write-Host "Installing backend dependencies..." -ForegroundColor Yellow +$activateScript = Join-Path $venvPath "Scripts\Activate.ps1" +if (Test-Path $activateScript) { + & $activateScript +} else { + Write-Host "ERROR: Virtual environment activation script not found" -ForegroundColor Red + exit 1 +} + +# Check if requirements are installed +$pipList = pip list 2>$null +if (-not ($pipList -like "*fastapi*")) { + Write-Host "Installing Python packages..." -ForegroundColor Yellow + pip install -e . 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to install backend dependencies" -ForegroundColor Red + Write-Host "Try running: pip install -e . manually in the backend directory" -ForegroundColor Yellow + exit 1 + } +} + +Write-Host "✓ Backend dependencies installed" -ForegroundColor Green + +# Setup frontend +Write-Host "" +Write-Host "Setting up Frontend..." -ForegroundColor Cyan +Set-Location $frontendDir + +# Check if node_modules exists +if (-not (Test-Path "node_modules")) { + Write-Host "Installing frontend dependencies..." -ForegroundColor Yellow + npm install 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Failed to install frontend dependencies" -ForegroundColor Red + Write-Host "Try running: npm install manually in the frontend directory" -ForegroundColor Yellow + exit 1 + } +} + +Write-Host "✓ Frontend dependencies installed" -ForegroundColor Green +Write-Host "" + +# Start servers +Write-Host "Starting Development Servers..." -ForegroundColor Cyan +Write-Host "================================" -ForegroundColor Cyan +Write-Host "" + +# Start backend server in a new PowerShell window +Write-Host "Starting Backend Server on http://localhost:5005" -ForegroundColor Green +$backendScript = @" +Write-Host 'GSPro Remote Backend Server' -ForegroundColor Cyan +Write-Host '===========================' -ForegroundColor Cyan +Write-Host '' +Write-Host 'Server: http://localhost:5005' -ForegroundColor Yellow +Write-Host 'API Docs: http://localhost:5005/api/docs' -ForegroundColor Yellow +Write-Host 'mDNS: http://gsproapp.local:5005' -ForegroundColor Yellow +Write-Host '' +Set-Location '$backendDir' +& '$activateScript' +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 5005 --log-level info +"@ + +$backendScriptPath = Join-Path $env:TEMP "gspro-backend.ps1" +$backendScript | Out-File -FilePath $backendScriptPath -Encoding UTF8 +Start-Process powershell -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $backendScriptPath + +# Give backend a moment to start +Start-Sleep -Seconds 2 + +# Start frontend server in a new PowerShell window +Write-Host "Starting Frontend Server on http://localhost:5173" -ForegroundColor Green +$frontendScript = @" +Write-Host 'GSPro Remote Frontend Server' -ForegroundColor Cyan +Write-Host '============================' -ForegroundColor Cyan +Write-Host '' +Write-Host 'UI: http://localhost:5173' -ForegroundColor Yellow +Write-Host '' +Set-Location '$frontendDir' +npm run dev +"@ + +$frontendScriptPath = Join-Path $env:TEMP "gspro-frontend.ps1" +$frontendScript | Out-File -FilePath $frontendScriptPath -Encoding UTF8 +Start-Process powershell -ArgumentList "-NoExit", "-ExecutionPolicy", "Bypass", "-File", $frontendScriptPath + +Write-Host "" +Write-Host "Development servers starting..." -ForegroundColor Green +Write-Host "" +Write-Host "Access Points:" -ForegroundColor Cyan +Write-Host " Frontend UI: http://localhost:5173" -ForegroundColor White +Write-Host " Backend API: http://localhost:5005" -ForegroundColor White +Write-Host " API Docs: http://localhost:5005/api/docs" -ForegroundColor White +Write-Host " mDNS Access: http://gsproapp.local:5005" -ForegroundColor White +Write-Host "" +Write-Host "Press any key to stop watching for changes..." -ForegroundColor Yellow + +# Wait for user input +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") + +# The servers are running in separate windows, so they'll keep running +# This script can exit, and the user can close the server windows manually +Write-Host "" +Write-Host "Note: The servers are still running in separate windows." -ForegroundColor Yellow +Write-Host "Close those windows to stop the servers." -ForegroundColor Yellow diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..c77214b --- /dev/null +++ b/start.bat @@ -0,0 +1,94 @@ +@echo off +echo =============================================== +echo GSPro Remote - Starting Development Servers +echo =============================================== +echo. + +REM Check if Python is installed +python --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: Python is not installed or not in PATH + echo Please install Python 3.11+ from https://www.python.org/ + pause + exit /b 1 +) + +REM Check if Node.js is installed +node --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: Node.js is not installed or not in PATH + echo Please install Node.js 20+ from https://nodejs.org/ + pause + exit /b 1 +) + +REM Get the directory where this script is located +set SCRIPT_DIR=%~dp0 +set PROJECT_ROOT=%SCRIPT_DIR% +set BACKEND_DIR=%PROJECT_ROOT%backend +set FRONTEND_DIR=%PROJECT_ROOT%frontend + +echo Project Root: %PROJECT_ROOT% +echo. + +REM Check if backend virtual environment exists +if not exist "%BACKEND_DIR%\.venv" ( + echo Creating Python virtual environment... + cd /d "%BACKEND_DIR%" + python -m venv .venv +) + +REM Install backend dependencies +echo Checking backend dependencies... +cd /d "%BACKEND_DIR%" +call .venv\Scripts\activate.bat +pip show fastapi >nul 2>&1 +if %errorlevel% neq 0 ( + echo Installing backend dependencies... + pip install -e . +) + +REM Check if frontend node_modules exists +if not exist "%FRONTEND_DIR%\node_modules" ( + echo Installing frontend dependencies... + cd /d "%FRONTEND_DIR%" + npm install +) + +echo. +echo =============================================== +echo Starting Backend Server on port 5005... +echo =============================================== +cd /d "%BACKEND_DIR%" +start "GSPro Remote Backend" cmd /k ".venv\Scripts\activate.bat && python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 5005" + +REM Wait a moment for backend to start +timeout /t 3 /nobreak >nul + +echo. +echo =============================================== +echo Starting Frontend Server on port 5173... +echo =============================================== +cd /d "%FRONTEND_DIR%" +start "GSPro Remote Frontend" cmd /k "npm run dev -- --host" + +REM Wait for servers to initialize +timeout /t 3 /nobreak >nul + +echo. +echo =============================================== +echo Servers are starting up... +echo =============================================== +echo. +echo Access the application at: +echo Local: http://localhost:5173 +echo Network: http://YOUR-IP:5173 +echo Backend API: http://localhost:5005 +echo API Docs: http://localhost:5005/api/docs +echo. +echo To find your IP address, run: ipconfig +echo. +echo Both servers are running in separate windows. +echo Close those windows to stop the servers. +echo. +pause diff --git a/start.py b/start.py new file mode 100644 index 0000000..5343aad --- /dev/null +++ b/start.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +GSPro Remote - Development Server Startup Script +Simple Python script to start both backend and frontend servers +""" + +import os +import platform +import subprocess +import sys +import time +import webbrowser +from pathlib import Path + + +# Colors for terminal output +class Colors: + HEADER = "\033[95m" + OKBLUE = "\033[94m" + OKCYAN = "\033[96m" + OKGREEN = "\033[92m" + WARNING = "\033[93m" + FAIL = "\033[91m" + ENDC = "\033[0m" + BOLD = "\033[1m" + + +def print_header(text): + print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 50}{Colors.ENDC}") + print(f"{Colors.HEADER}{Colors.BOLD}{text}{Colors.ENDC}") + print(f"{Colors.HEADER}{Colors.BOLD}{'=' * 50}{Colors.ENDC}\n") + + +def print_success(text): + print(f"{Colors.OKGREEN}✓ {text}{Colors.ENDC}") + + +def print_error(text): + print(f"{Colors.FAIL}✗ {text}{Colors.ENDC}") + + +def print_info(text): + print(f"{Colors.OKCYAN}→ {text}{Colors.ENDC}") + + +def check_python(): + """Check if Python is installed and version is 3.11+""" + version = sys.version_info + if version.major < 3 or (version.major == 3 and version.minor < 11): + print_error(f"Python 3.11+ required, found {version.major}.{version.minor}") + return False + print_success(f"Python {version.major}.{version.minor} found") + return True + + +def check_node(): + """Check if Node.js is installed""" + try: + result = subprocess.run(["node", "--version"], capture_output=True, text=True) + if result.returncode == 0: + print_success(f"Node.js {result.stdout.strip()} found") + return True + except FileNotFoundError: + pass + print_error("Node.js not found. Please install from https://nodejs.org/") + return False + + +def check_npm(): + """Check if npm is installed""" + try: + result = subprocess.run(["npm", "--version"], capture_output=True, text=True) + if result.returncode == 0: + print_success(f"npm {result.stdout.strip()} found") + return True + except FileNotFoundError: + pass + print_error("npm not found") + return False + + +def setup_backend(backend_dir): + """Setup backend virtual environment and dependencies""" + venv_path = backend_dir / ".venv" + + # Create virtual environment if it doesn't exist + if not venv_path.exists(): + print_info("Creating Python virtual environment...") + subprocess.run([sys.executable, "-m", "venv", str(venv_path)], cwd=backend_dir) + + # Determine the activation script based on OS + if platform.system() == "Windows": + activate_script = venv_path / "Scripts" / "activate.bat" + python_exe = venv_path / "Scripts" / "python.exe" + pip_exe = venv_path / "Scripts" / "pip.exe" + else: + activate_script = venv_path / "bin" / "activate" + python_exe = venv_path / "bin" / "python" + pip_exe = venv_path / "bin" / "pip" + + # Check if dependencies are installed + result = subprocess.run( + [str(pip_exe), "show", "fastapi"], capture_output=True, cwd=backend_dir + ) + + if result.returncode != 0: + print_info("Installing backend dependencies...") + subprocess.run([str(pip_exe), "install", "-e", "."], cwd=backend_dir) + else: + print_success("Backend dependencies already installed") + + return python_exe + + +def setup_frontend(frontend_dir): + """Setup frontend dependencies""" + node_modules = frontend_dir / "node_modules" + + if not node_modules.exists(): + print_info("Installing frontend dependencies...") + subprocess.run(["npm", "install"], cwd=frontend_dir, shell=True) + else: + print_success("Frontend dependencies already installed") + + +def get_local_ip(): + """Get local IP address""" + import socket + + try: + # Connect to a public DNS server to get local IP + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except: + return "localhost" + + +def main(): + print_header("GSPro Remote - Development Server Startup") + + # Get project directories + script_dir = Path(__file__).parent.resolve() + backend_dir = script_dir / "backend" + frontend_dir = script_dir / "frontend" + + print(f"Project root: {script_dir}") + + # Check prerequisites + print_header("Checking Prerequisites") + if not check_python(): + sys.exit(1) + if not check_node(): + sys.exit(1) + if not check_npm(): + sys.exit(1) + + # Setup backend + print_header("Setting up Backend") + os.chdir(backend_dir) + python_exe = setup_backend(backend_dir) + + # Setup frontend + print_header("Setting up Frontend") + os.chdir(frontend_dir) + setup_frontend(frontend_dir) + + # Start backend server + print_header("Starting Backend Server") + backend_cmd = [ + str(python_exe), + "-m", + "uvicorn", + "app.main:app", + "--reload", + "--host", + "0.0.0.0", + "--port", + "5005", + "--log-level", + "info", + ] + + if platform.system() == "Windows": + backend_process = subprocess.Popen( + backend_cmd, cwd=backend_dir, creationflags=subprocess.CREATE_NEW_CONSOLE + ) + else: + backend_process = subprocess.Popen(backend_cmd, cwd=backend_dir) + + print_success("Backend server starting on http://localhost:5005") + + # Wait for backend to start + time.sleep(3) + + # Start frontend server + print_header("Starting Frontend Server") + frontend_cmd = "npm run dev -- --host" + + if platform.system() == "Windows": + frontend_process = subprocess.Popen( + frontend_cmd, + shell=True, + cwd=frontend_dir, + creationflags=subprocess.CREATE_NEW_CONSOLE, + ) + else: + frontend_process = subprocess.Popen(frontend_cmd, shell=True, cwd=frontend_dir) + + print_success("Frontend server starting on http://localhost:5173") + + # Get local IP + local_ip = get_local_ip() + + # Print access information + print_header("Servers Started Successfully!") + print(f"{Colors.OKGREEN}Access the application at:{Colors.ENDC}") + print(f" {Colors.BOLD}Local:{Colors.ENDC} http://localhost:5173") + print(f" {Colors.BOLD}Network:{Colors.ENDC} http://{local_ip}:5173") + print(f" {Colors.BOLD}Backend API:{Colors.ENDC} http://localhost:5005") + print(f" {Colors.BOLD}API Docs:{Colors.ENDC} http://localhost:5005/api/docs") + print(f" {Colors.BOLD}mDNS:{Colors.ENDC} http://gsproapp.local:5005") + + print(f"\n{Colors.WARNING}To access from your phone/tablet:{Colors.ENDC}") + print(f"1. Ensure your device is on the same WiFi network") + print(f"2. Open a browser and go to: http://{local_ip}:5173") + + print(f"\n{Colors.BOLD}Press Ctrl+C to stop all servers{Colors.ENDC}\n") + + # Keep script running and handle shutdown + try: + # Open browser after a short delay + time.sleep(2) + webbrowser.open("http://localhost:5173") + + # Wait for processes + backend_process.wait() + frontend_process.wait() + except KeyboardInterrupt: + print(f"\n{Colors.WARNING}Shutting down servers...{Colors.ENDC}") + backend_process.terminate() + frontend_process.terminate() + print_success("Servers stopped") + sys.exit(0) + + +if __name__ == "__main__": + main()