diff --git a/.dockerignore b/.dockerignore index 1b11b95..a28ffb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,6 @@ data openapi src +# Frontend host build artifacts — built inside the node stage, not needed from context +frontend/node_modules +frontend/dist diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..c38f58e --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,49 @@ +name: frontend + +on: + push: + branches: + - "**" + pull_request: + workflow_dispatch: + +jobs: + frontend: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + working-directory: frontend + run: npm ci + + - name: Check codegen is in sync + working-directory: frontend + run: | + npm run codegen + git diff --exit-code src/api/schema.d.ts + + - name: Lint + working-directory: frontend + run: npm run lint + + - name: Type-check + working-directory: frontend + run: npm run typecheck + + - name: Test + working-directory: frontend + run: npm run test + + - name: Build + working-directory: frontend + run: npm run build diff --git a/Dockerfile b/Dockerfile index d06d5b1..ded6532 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,15 @@ +# Stage 1: build the React SPA +FROM node:22-slim AS frontend-build + +WORKDIR /frontend + +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + +# Stage 2: python runtime (no node) FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -16,6 +28,9 @@ COPY docker ./docker COPY README.md ./ RUN mkdir -p /app/data +# Copy the built SPA dist from the frontend-build stage +COPY --from=frontend-build /frontend/dist ./frontend/dist + EXPOSE 8000 ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/README.md b/README.md index c1fb3b9..16e5510 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 当前系统已经包含: -- FastAPI Web 应用与服务端模板页面 +- FastAPI Web 应用(React SPA 前端 + JSON API) - SQLite + SQLAlchemy + Alembic 的单库结构 - username/password + server-side session 鉴权 - runtime config 页面与 app DB 持久化 @@ -47,11 +47,13 @@ python -m scripts.run_migrations 主要目录如下: -- `app/`: FastAPI 应用代码 +- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型) +- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine) - `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表) - `tests/`: pytest 测试 - `docs/`: 当前系统说明文档 - `scripts/`: 辅助脚本,例如 OpenAPI 导出 +- `openapi/`: OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),纳入版本控制 ## 依赖管理 @@ -112,11 +114,62 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 启动后可访问: -- 应用首页:`http://localhost:8000/` +- 应用首页(React SPA):`http://localhost:8000/` - 健康检查:`http://localhost:8000/status` - Swagger UI:`http://localhost:8000/docs` - ReDoc:`http://localhost:8000/redoc` +## 前端 v2(React SPA) + +M2 用 React SPA 取代了原有 Jinja 服务端模板,由 FastAPI 同源托管(同一容器、同一 origin)。 + +### 技术栈 + +- **Vite + React + TypeScript + Mantine**(组件库) +- **TanStack Query**(数据请求/缓存) +- **Leaflet / react-leaflet**(地图与热力图) +- **openapi-typescript + openapi-fetch**(类型化 API client,由 `openapi/openapi.json` 生成) + +### 本地开发(前端) + +前端开发服务器会把 `/api`、`/location`、`/poo`、`/public-ip`、`/homeassistant`、`/ticktick`、`/status` 等路径代理到后端 FastAPI(`:8000`)。 + +```bash +cd frontend +npm install +npm run dev # 启动 Vite dev server(默认 :5173),代理后端 +``` + +### 构建 + +```bash +cd frontend +npm run build # 产出 frontend/dist +``` + +FastAPI 启动时若 `frontend/dist/index.html` 存在,则自动挂载该目录,并对非 `/api` 路径做 SPA fallback(返回 `index.html`)。该路径可通过环境变量 `SPA_DIST_DIR` 覆盖(默认值为 `frontend/dist`,与多阶段 Dockerfile 中 `COPY` 到 `/app/frontend/dist` 一致)。 + +### 类型化 API Client + +前端 API client 由后端 OpenAPI schema 自动生成: + +```bash +cd frontend +npm run codegen # 从 ../openapi/openapi.json 生成 src/api/schema.d.ts +``` + +生成物(`src/api/schema.d.ts`)已提交入库,CI 会校验它与 `openapi/openapi.json` 保持同步。 + +### 前端校验闸门 + +```bash +cd frontend +npm run lint # ESLint +npm run typecheck # TypeScript 类型检查 +npm run test # Vitest 单元测试 +npm run build # 构建,确认产出 dist +``` + ## 数据库与 Alembic 当前使用单一 SQLite 数据库文件: @@ -142,9 +195,9 @@ python -m scripts.migrate_legacy_data - 认证模型:`username/password` - 会话模型:server-side session + cookie -- 当前主要受保护页面:`/config` -- 当前公开页面:`/login` -- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下 +- 当前受保护入口:React SPA(`/` 等客户端路由)调用 `/api/*` JSON 端点 +- 当前公开页面:`/login`(SPA 登录页) +- 当前公开 API:裸 ingestion 端点(`/location/record`、`/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做) 安全实现的当前边界: @@ -152,7 +205,7 @@ python -m scripts.migrate_legacy_data - session cookie 使用 `HttpOnly` - `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启 - `SameSite=Lax` -- 登录表单和登出表单都有基础 CSRF 防护 +- 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` header(SameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对) 首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用: @@ -166,12 +219,14 @@ python -m scripts.migrate_legacy_data 首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。 -当前前端主要有两条页面路径: +React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`): -- `/login` -- `/config` +- `/login`:登录页 +- `/`:首页(地图热力图主视图) +- `/config`:配置页(取代原 Jinja `/config`) +- `/records`:记录管理列表页 -无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`。 +无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`)。 ## Config 持久化 @@ -230,8 +285,8 @@ python -m scripts.migrate_legacy_data 当前系统已经提供最小可用的 SMTP 能力: -- SMTP 配置可在 `/config` 页面填写并保存到 `app_config` -- 可通过 config 页面发送测试邮件 +- SMTP 配置可在 React SPA `/config` 页面填写并保存到 `app_config`(通过 `PUT /api/config`) +- 可通过 config 页面发送测试邮件(`POST /api/config/smtp/test`) - 邮件 `From` 头支持显示名,例如 `Home Automation ` 当前 SMTP 配置项包括: @@ -283,18 +338,20 @@ python scripts/export_openapi.py 当前 Compose 分成两层: -- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取 -- `docker-compose.override.yml`:仅为本地开发追加 `build: .` +- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881) +- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project / + 容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存 -本地开发启动方式: +本地开发启动方式(显式叠加 dev 层): ```bash -docker compose up -d --build +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build ``` -上面的命令会自动叠加 `docker-compose.override.yml`,因此本地仍然会按当前工作目录重新 build。 +dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名, +因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。 -如果要按生产方式直接从 registry 拉取并启动,显式只使用基础 compose 文件: +如果要按生产方式直接从 registry 拉取并启动,使用基础 compose 文件: ```bash docker compose -f docker-compose.yml pull diff --git a/app/api/routes/api/__init__.py b/app/api/routes/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/api/config.py b/app/api/routes/api/config.py new file mode 100644 index 0000000..ee91333 --- /dev/null +++ b/app/api/routes/api/config.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session + +from app.api.routes.api.deps import require_csrf, require_session +from app.config import Settings, get_settings +from app.dependencies import get_app_settings, get_db +from app.schemas.config import ( + ConfigField, + ConfigResponse, + ConfigSection, + ConfigUpdateRequest, + ConfigUpdateResponse, + SmtpTestResponse, +) +from app.services.auth import AuthenticatedSession +from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates +from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["api-config"]) + + +def _sections_from_raw(sections_raw: list[dict]) -> list[ConfigSection]: + result = [] + for section in sections_raw: + fields = [ConfigField(**f) for f in section["fields"]] + result.append(ConfigSection(name=section["name"], fields=fields)) + return result + + +@router.get("/config", response_model=ConfigResponse) +def get_config( + db: Session = Depends(get_db), + settings: Settings = Depends(get_app_settings), + _auth: AuthenticatedSession = Depends(require_session), +) -> ConfigResponse: + """Return all configuration sections. Secret field values are masked (empty string).""" + sections_raw = build_config_sections(db, settings) + return ConfigResponse(sections=_sections_from_raw(sections_raw)) + + +@router.put("/config", response_model=ConfigUpdateResponse) +def put_config( + body: ConfigUpdateRequest, + db: Session = Depends(get_db), + settings: Settings = Depends(get_app_settings), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> ConfigUpdateResponse: + """ + Save configuration updates. + + - Blank secret value keeps the existing stored value (no change). + - Invalid values return 422 and nothing is written to the database. + """ + try: + save_config_updates(db, body.updates, settings) + except ConfigSaveError as exc: + logger.warning("Rejected config update via API: %s", exc) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="invalid config submission", + ) from exc + + # Re-read settings after save (save_config_updates clears the settings cache) + refreshed_settings = get_settings() + sections_raw = build_config_sections(db, refreshed_settings) + return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw)) + + +@router.post( + "/config/smtp/test", + responses={ + 200: {"model": SmtpTestResponse}, + 400: {"model": SmtpTestResponse}, + 502: {"model": SmtpTestResponse}, + }, +) +def post_smtp_test( + settings: Settings = Depends(get_app_settings), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> JSONResponse: + """ + Send a test SMTP email using the current runtime settings. + + Returns a structured result indicating success or the category of failure. + Three possible outcomes: + - 200 { "result": "success", "message": ... } + - 400 { "result": "config-error", "message": ... } (EmailConfigurationError) + - 502 { "result": "failed", "message": ... } (EmailDeliveryError) + + SMTP credentials are never echoed in the response. + """ + try: + send_smtp_test_email(settings) + except EmailConfigurationError as exc: + logger.warning("SMTP test rejected due to configuration: %s", exc) + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content={"result": "config-error", "message": str(exc)}, + ) + except EmailDeliveryError as exc: + logger.warning("SMTP test delivery failed: %s", exc) + return JSONResponse( + status_code=status.HTTP_502_BAD_GATEWAY, + content={"result": "failed", "message": str(exc)}, + ) + + return JSONResponse( + status_code=status.HTTP_200_OK, + content={"result": "success", "message": "Test email sent successfully."}, + ) diff --git a/app/api/routes/api/data.py b/app/api/routes/api/data.py new file mode 100644 index 0000000..7673b32 --- /dev/null +++ b/app/api/routes/api/data.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from sqlalchemy import desc, select +from sqlalchemy.orm import Session + +from app.api.routes.api.deps import require_csrf, require_session +from app.dependencies import get_db +from app.models.location import Location +from app.models.poo import PooRecord +from app.models.public_ip import PublicIPHistory, PublicIPState +from app.schemas.data import ( + LocationRecord, + LocationUpdateRequest, + LocationsResponse, + PooRecord as PooRecordSchema, + PooResponse, + PooUpdateRequest, + PublicIPHistorySchema, + PublicIPResponse, + PublicIPStateSchema, +) +from app.services.auth import AuthenticatedSession +from app.services.location import delete_location, update_location +from app.services.poo import delete_poo_record, update_poo_record + +router = APIRouter(prefix="/api", tags=["api-data"]) + + +@router.get("/locations", response_model=LocationsResponse) +def get_locations( + limit: int = Query(default=1000, ge=1, le=5000), + offset: int = Query(default=0, ge=0), + start: str | None = Query(default=None), + end: str | None = Query(default=None), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> LocationsResponse: + """ + Return location records with optional time-window filtering and pagination. + + - ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds. + - Results are ordered by ``datetime`` ascending. + - ``limit`` is capped at 5000 to prevent full-table exports. + """ + stmt = select(Location) + + if start is not None: + stmt = stmt.where(Location.datetime >= start) + if end is not None: + stmt = stmt.where(Location.datetime <= end) + + stmt = stmt.order_by(Location.datetime).offset(offset).limit(limit) + + rows = db.execute(stmt).scalars().all() + + items = [ + LocationRecord( + person=row.person, + datetime=row.datetime, + latitude=row.latitude, + longitude=row.longitude, + altitude=row.altitude, + ) + for row in rows + ] + + return LocationsResponse(items=items, limit=limit, offset=offset) + + +@router.get("/poo", response_model=PooResponse) +def get_poo( + limit: int = Query(default=100, ge=1, le=1000), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> PooResponse: + """ + Return poo records ordered by timestamp descending (most recent first). + + ``limit`` is capped at 1000 to prevent full-table exports. + """ + stmt = ( + select(PooRecord) + .order_by(desc(PooRecord.timestamp)) + .offset(offset) + .limit(limit) + ) + + rows = db.execute(stmt).scalars().all() + + items = [ + PooRecordSchema( + timestamp=row.timestamp, + status=row.status, + latitude=row.latitude, + longitude=row.longitude, + ) + for row in rows + ] + + return PooResponse(items=items, limit=limit, offset=offset) + + +@router.get("/public-ip", response_model=PublicIPResponse) +def get_public_ip( + limit: int = Query(default=100, ge=1, le=1000), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> PublicIPResponse: + """ + Return the current public IP state and recent history. + + - ``state`` is ``null`` if no IP check has been performed yet. + - ``history`` is ordered by ``observed_at`` descending (most recent first). + - ``limit`` applies to the history list and is capped at 1000. + """ + state_row = db.execute( + select(PublicIPState).where(PublicIPState.id == 1).limit(1) + ).scalar_one_or_none() + + history_rows = db.execute( + select(PublicIPHistory).order_by(desc(PublicIPHistory.observed_at)).limit(limit) + ).scalars().all() + + state = PublicIPStateSchema.model_validate(state_row) if state_row is not None else None + history = [PublicIPHistorySchema.model_validate(row) for row in history_rows] + + return PublicIPResponse(state=state, history=history) + + +# --------------------------------------------------------------------------- +# PATCH /api/locations/{person}/{datetime} +# --------------------------------------------------------------------------- + + +@router.patch("/locations/{person}/{datetime}", response_model=LocationRecord) +def patch_location( + person: str, + datetime: str, + body: LocationUpdateRequest = Body(default=LocationUpdateRequest()), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> LocationRecord: + """ + Update the non-PK fields of a single location record. + + - ``person`` and ``datetime`` identify the row (composite PK) and are immutable. + - Only ``latitude``, ``longitude``, and ``altitude`` may be updated. + - Omitted body fields are left unchanged. + - Returns **404** if the PK does not exist. + """ + row = update_location( + db, + person, + datetime, + latitude=body.latitude, + longitude=body.longitude, + altitude=body.altitude, + ) + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="location record not found", + ) + return LocationRecord( + person=row.person, + datetime=row.datetime, + latitude=row.latitude, + longitude=row.longitude, + altitude=row.altitude, + ) + + +# --------------------------------------------------------------------------- +# DELETE /api/locations/{person}/{datetime} +# --------------------------------------------------------------------------- + + +@router.delete( + "/locations/{person}/{datetime}", + status_code=status.HTTP_204_NO_CONTENT, + response_model=None, +) +def delete_location_record( + person: str, + datetime: str, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> None: + """ + Delete the single location record identified by its composite PK. + + - Exactly one row is deleted; **404** if the PK does not exist. + - No batch delete / truncate path is available. + """ + deleted = delete_location(db, person, datetime) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="location record not found", + ) + + +# --------------------------------------------------------------------------- +# PATCH /api/poo/{timestamp} +# --------------------------------------------------------------------------- + + +@router.patch("/poo/{timestamp}", response_model=PooRecordSchema) +def patch_poo( + timestamp: str, + body: PooUpdateRequest = Body(default=PooUpdateRequest()), + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> PooRecordSchema: + """ + Update the non-PK fields of a single poo record. + + - ``timestamp`` is the PK and is immutable. + - Only ``status``, ``latitude``, and ``longitude`` may be updated. + - Omitted body fields are left unchanged. + - Returns **404** if the PK does not exist. + """ + row = update_poo_record( + db, + timestamp, + status=body.status, + latitude=body.latitude, + longitude=body.longitude, + ) + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="poo record not found", + ) + return PooRecordSchema( + timestamp=row.timestamp, + status=row.status, + latitude=row.latitude, + longitude=row.longitude, + ) + + +# --------------------------------------------------------------------------- +# DELETE /api/poo/{timestamp} +# --------------------------------------------------------------------------- + + +@router.delete( + "/poo/{timestamp}", + status_code=status.HTTP_204_NO_CONTENT, + response_model=None, +) +def delete_poo( + timestamp: str, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> None: + """ + Delete the single poo record identified by its PK. + + - Exactly one row is deleted; **404** if the PK does not exist. + - No batch delete / truncate path is available. + """ + deleted = delete_poo_record(db, timestamp) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="poo record not found", + ) diff --git a/app/api/routes/api/deps.py b/app/api/routes/api/deps.py new file mode 100644 index 0000000..681631f --- /dev/null +++ b/app/api/routes/api/deps.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from fastapi import Depends, Header, HTTPException, status + +from app.dependencies import get_current_auth_session +from app.services.auth import AuthenticatedSession + + +def require_session( + auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> AuthenticatedSession: + if auth is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="authentication required", + ) + return auth + + +def require_csrf( + _auth: AuthenticatedSession = Depends(require_session), + x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"), +) -> None: + if not x_csrf_token: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="missing CSRF token", + ) diff --git a/app/api/routes/api/session.py b/app/api/routes/api/session.py new file mode 100644 index 0000000..6592c55 --- /dev/null +++ b/app/api/routes/api/session.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from app.api.routes.api.deps import require_csrf, require_session +from app.config import Settings +from app.dependencies import get_app_settings, get_db +from app.schemas.session import ( + LoginRequest, + PasswordChangeRequest, + SessionResponse, + SessionUser, +) +from app.services.auth import ( + AuthPasswordChangeError, + AuthenticatedSession, + authenticate_user, + change_password, + create_session, + revoke_session, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["api-session"]) + + +def _build_session_response(auth: AuthenticatedSession) -> SessionResponse: + return SessionResponse( + user=SessionUser( + username=auth.user.username, + force_password_change=auth.user.force_password_change, + ), + csrf_token=auth.session.csrf_token, + ) + + +@router.get("/session", response_model=SessionResponse) +def get_session( + auth: AuthenticatedSession = Depends(require_session), +) -> SessionResponse: + """Return the current session user and CSRF token. Returns 401 if not authenticated.""" + return _build_session_response(auth) + + +@router.post("/auth/login", response_model=SessionResponse) +def post_login( + body: LoginRequest, + response: Response, + db: Session = Depends(get_db), + settings: Settings = Depends(get_app_settings), +) -> SessionResponse: + """ + Authenticate with username and password. + + On success, sets an HttpOnly session cookie and returns the session user + CSRF token. + On failure, returns 401 with no cookie set. + No X-CSRF-Token required (unauthenticated endpoint). + """ + user = authenticate_user(db, username=body.username, password=body.password) + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) + + auth_session, raw_token = create_session(db, user=user, settings=settings) + logger.info("Created API authenticated session for user '%s'", user.username) + + response.set_cookie( + key=settings.auth_session_cookie_name, + value=raw_token, + max_age=settings.auth_session_ttl_hours * 3600, + httponly=True, + secure=settings.auth_cookie_secure, + samesite="lax", + path="/", + ) + + auth = AuthenticatedSession(user=user, session=auth_session) + return _build_session_response(auth) + + +@router.post("/auth/logout") +def post_logout( + response: Response, + db: Session = Depends(get_db), + settings: Settings = Depends(get_app_settings), + auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> Response: + """ + Revoke the current session and clear the session cookie. + Requires authentication and X-CSRF-Token header. + Returns 204 No Content. + """ + revoke_session(db, auth_session=auth.session) + logger.info("Revoked API authenticated session for user '%s'", auth.user.username) + no_content = Response(status_code=status.HTTP_204_NO_CONTENT) + no_content.delete_cookie(settings.auth_session_cookie_name, path="/") + return no_content + + +@router.post("/auth/password") +def post_change_password( + body: PasswordChangeRequest, + db: Session = Depends(get_db), + auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> Response: + """ + Change the current user's password. + Requires authentication and X-CSRF-Token header. + On AuthPasswordChangeError returns 400 with a generic message. + On success, force_password_change becomes False (handled by the service). + Returns 204 No Content. + """ + try: + change_password( + db, + user=auth.user, + current_password=body.current_password, + new_password=body.new_password, + confirm_password=body.confirm_password, + ) + except AuthPasswordChangeError as exc: + logger.info( + "Rejected password change for user '%s': %s", + auth.user.username, + exc, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="password change failed", + ) from exc + + logger.info("Password updated for user '%s'", auth.user.username) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py deleted file mode 100644 index d80846f..0000000 --- a/app/api/routes/auth.py +++ /dev/null @@ -1,234 +0,0 @@ -import logging -from pathlib import Path - -from fastapi import APIRouter, Depends, Form, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse, Response -from fastapi.templating import Jinja2Templates -from sqlalchemy.orm import Session - -from app.config import Settings -from app.dependencies import get_app_settings, get_db, get_current_auth_session -from app.services.auth import ( - AuthenticatedSession, - authenticate_user, - change_password, - create_session, - AuthPasswordChangeError, - issue_login_csrf_token, - revoke_session, - validate_csrf_token, -) -from app.services.config_page import build_config_sections, is_ticktick_oauth_ready - -logger = logging.getLogger(__name__) -templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) -router = APIRouter(tags=["auth"]) - -LOGIN_CSRF_COOKIE_NAME = "login_csrf" - - -@router.get("/login", response_class=HTMLResponse) -def login_page( - request: Request, - settings: Settings = Depends(get_app_settings), - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> Response: - if current_auth is not None: - return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) - - csrf_token = issue_login_csrf_token() - response = templates.TemplateResponse( - request, - "login.html", - { - "app_name": settings.app_name, - "app_env": settings.app_env, - "csrf_token": csrf_token, - "error_message": None, - }, - ) - _set_login_csrf_cookie(response, settings=settings, token=csrf_token) - return response - - -@router.post("/login", response_class=HTMLResponse) -def login_submit( - request: Request, - username: str = Form(), - password: str = Form(), - csrf_token: str = Form(), - session: Session = Depends(get_db), - settings: Settings = Depends(get_app_settings), -) -> Response: - cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME) - if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token): - logger.warning("Rejected login attempt due to CSRF validation failure") - return _render_login_error( - request, - settings=settings, - status_code=status.HTTP_400_BAD_REQUEST, - error_message="invalid login request", - ) - - user = authenticate_user(session, username=username, password=password) - if user is None: - return _render_login_error( - request, - settings=settings, - status_code=status.HTTP_401_UNAUTHORIZED, - error_message="invalid username or password", - ) - - auth_session, raw_token = create_session(session, user=user, settings=settings) - response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login") - response.set_cookie( - key=settings.auth_session_cookie_name, - value=raw_token, - max_age=settings.auth_session_ttl_hours * 3600, - httponly=True, - secure=settings.auth_cookie_secure, - samesite="lax", - path="/", - ) - logger.info("Created authenticated session for user '%s'", user.username) - return response - - -@router.post("/config/change-password", response_class=HTMLResponse) -def change_password_submit( - request: Request, - current_password: str = Form(), - new_password: str = Form(), - confirm_password: str = Form(), - csrf_token: str = Form(), - session: Session = Depends(get_db), - settings: Settings = Depends(get_app_settings), - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> Response: - if current_auth is None: - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - - if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token): - logger.warning("Rejected password change attempt due to CSRF validation failure") - return _render_config_page( - request, - settings=settings, - auth_db_session=session, - current_auth=current_auth, - status_code=status.HTTP_400_BAD_REQUEST, - password_change_error="invalid password change request", - ) - - try: - change_password( - session, - user=current_auth.user, - current_password=current_password, - new_password=new_password, - confirm_password=confirm_password, - ) - except AuthPasswordChangeError as exc: - logger.info( - "Rejected password change for user '%s': %s", - current_auth.user.username, - exc, - ) - return _render_config_page( - request, - settings=settings, - auth_db_session=session, - current_auth=current_auth, - status_code=status.HTTP_400_BAD_REQUEST, - password_change_error="password change failed", - ) - - logger.info("Password updated for user '%s'", current_auth.user.username) - return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post("/logout") -def logout( - request: Request, - csrf_token: str = Form(), - session: Session = Depends(get_db), - settings: Settings = Depends(get_app_settings), - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> RedirectResponse: - if current_auth is not None and validate_csrf_token( - expected=current_auth.session.csrf_token, actual=csrf_token - ): - revoke_session(session, auth_session=current_auth.session) - logger.info("Revoked authenticated session for user '%s'", current_auth.user.username) - else: - logger.warning("Rejected logout request due to missing session or invalid CSRF token") - - response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(settings.auth_session_cookie_name, path="/") - return response - - -def _render_login_error( - request: Request, - *, - settings: Settings, - status_code: int, - error_message: str, -) -> HTMLResponse: - csrf_token = issue_login_csrf_token() - response = templates.TemplateResponse( - request, - "login.html", - { - "app_name": settings.app_name, - "app_env": settings.app_env, - "csrf_token": csrf_token, - "error_message": error_message, - }, - status_code=status_code, - ) - _set_login_csrf_cookie(response, settings=settings, token=csrf_token) - return response - - -def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None: - response.set_cookie( - key=LOGIN_CSRF_COOKIE_NAME, - value=token, - max_age=1800, - httponly=True, - secure=settings.auth_cookie_secure, - samesite="lax", - path="/login", - ) - - -def _render_config_page( - request: Request, - *, - settings: Settings, - auth_db_session: Session, - current_auth: AuthenticatedSession, - status_code: int, - password_change_error: str | None, -) -> HTMLResponse: - return templates.TemplateResponse( - request, - "config.html", - { - "app_name": settings.app_name, - "app_env": settings.app_env, - "current_username": current_auth.user.username, - "csrf_token": current_auth.session.csrf_token, - "force_password_change": current_auth.user.force_password_change, - "password_change_error": password_change_error, - "config_error": None, - "config_saved": False, - "config_sections": build_config_sections(auth_db_session, settings), - "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), - "ticktick_redirect_uri": settings.ticktick_redirect_uri, - "ticktick_oauth_notice": None, - "ticktick_oauth_error": None, - }, - status_code=status_code, - ) diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py deleted file mode 100644 index bbd2594..0000000 --- a/app/api/routes/pages.py +++ /dev/null @@ -1,240 +0,0 @@ -import logging -from pathlib import Path - -from fastapi import APIRouter, Depends, Request, status -from fastapi.responses import HTMLResponse, RedirectResponse, Response -from fastapi.templating import Jinja2Templates - -from app.config import Settings, get_settings -from app.dependencies import get_app_settings, get_db, get_current_auth_session -from app.services.auth import AuthenticatedSession -from app.services.config_page import ( - ConfigSaveError, - build_config_sections, - is_ticktick_oauth_ready, - save_config_updates, -) -from app.services.email import EmailConfigurationError, EmailDeliveryError, is_smtp_ready, send_smtp_test_email -from sqlalchemy.orm import Session - -templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) -router = APIRouter(tags=["pages"]) -logger = logging.getLogger(__name__) - - -def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]: - if status_value == "success": - return "TickTick authorization completed successfully.", None - if status_value == "invalid-state": - return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again." - if status_value == "invalid-callback": - return None, "TickTick authorization callback was missing required parameters." - if status_value == "failed": - return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." - return None, None - - -def _smtp_test_notice(status_value: str | None) -> tuple[str | None, str | None]: - if status_value == "success": - return "SMTP test email sent successfully.", None - if status_value == "config-error": - return None, "SMTP test failed. Check required SMTP settings before sending a test email." - if status_value == "failed": - return None, "SMTP test failed. Check saved SMTP settings and server reachability." - return None, None - - -def _build_config_context( - *, - auth_db_session: Session, - settings: Settings, - current_auth: AuthenticatedSession, - config_saved: bool, - config_error: str | None, - password_change_error: str | None, - ticktick_oauth_notice: str | None, - ticktick_oauth_error: str | None, - smtp_test_notice: str | None, - smtp_test_error: str | None, -) -> dict[str, object]: - return { - "app_name": settings.app_name, - "app_env": settings.app_env, - "current_username": current_auth.user.username, - "csrf_token": current_auth.session.csrf_token, - "force_password_change": current_auth.user.force_password_change, - "password_change_error": password_change_error, - "config_error": config_error, - "config_saved": config_saved, - "config_sections": build_config_sections(auth_db_session, settings), - "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), - "ticktick_redirect_uri": settings.ticktick_redirect_uri, - "ticktick_oauth_notice": ticktick_oauth_notice, - "ticktick_oauth_error": ticktick_oauth_error, - "smtp_test_ready": is_smtp_ready(settings), - "smtp_test_notice": smtp_test_notice, - "smtp_test_error": smtp_test_error, - } - - -@router.get("/", response_class=HTMLResponse) -def home( - request: Request, - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> RedirectResponse: - if current_auth is None: - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) - - -@router.get("/admin", response_class=HTMLResponse) -def admin_redirect( - request: Request, - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> RedirectResponse: - if current_auth is None: - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) - - -@router.get("/config", response_class=HTMLResponse) -def config_page( - request: Request, - auth_db_session: Session = Depends(get_db), - settings: Settings = Depends(get_app_settings), - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> Response: - if current_auth is None: - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - - ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice( - request.query_params.get("ticktick_oauth") - ) - smtp_test_notice, smtp_test_error = _smtp_test_notice(request.query_params.get("smtp_test")) - context = _build_config_context( - auth_db_session=auth_db_session, - settings=settings, - current_auth=current_auth, - config_saved=request.query_params.get("saved") == "1", - config_error=None, - password_change_error=None, - ticktick_oauth_notice=ticktick_oauth_notice, - ticktick_oauth_error=ticktick_oauth_error, - smtp_test_notice=smtp_test_notice, - smtp_test_error=smtp_test_error, - ) - return templates.TemplateResponse(request, "config.html", context) - - -@router.post("/config", response_class=HTMLResponse) -async def config_submit( - request: Request, - auth_db_session: Session = Depends(get_db), - settings: Settings = Depends(get_app_settings), - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> Response: - if current_auth is None: - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - - form = await request.form() - csrf_token = form.get("csrf_token") - if csrf_token != current_auth.session.csrf_token: - logger.warning("Rejected config update due to CSRF validation failure") - context = _build_config_context( - auth_db_session=auth_db_session, - settings=settings, - current_auth=current_auth, - config_saved=False, - config_error="invalid config update request", - password_change_error=None, - ticktick_oauth_notice=None, - ticktick_oauth_error=None, - smtp_test_notice=None, - smtp_test_error=None, - ) - return templates.TemplateResponse( - request, - "config.html", - context, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - try: - save_config_updates(auth_db_session, dict(form), settings) - except ConfigSaveError: - logger.warning("Rejected config update due to invalid submitted values") - refreshed_settings = get_settings() - context = _build_config_context( - auth_db_session=auth_db_session, - settings=refreshed_settings, - current_auth=current_auth, - config_saved=False, - config_error="invalid config submission", - password_change_error=None, - ticktick_oauth_notice=None, - ticktick_oauth_error=None, - smtp_test_notice=None, - smtp_test_error=None, - ) - return templates.TemplateResponse( - request, - "config.html", - context, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER) - - -@router.post("/config/smtp/test", response_class=HTMLResponse) -async def smtp_test_submit( - request: Request, - auth_db_session: Session = Depends(get_db), - settings: Settings = Depends(get_app_settings), - current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), -) -> Response: - if current_auth is None: - return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) - - form = await request.form() - csrf_token = form.get("csrf_token") - if csrf_token != current_auth.session.csrf_token: - logger.warning("Rejected SMTP test due to CSRF validation failure") - context = _build_config_context( - auth_db_session=auth_db_session, - settings=settings, - current_auth=current_auth, - config_saved=False, - config_error=None, - password_change_error=None, - ticktick_oauth_notice=None, - ticktick_oauth_error=None, - smtp_test_notice=None, - smtp_test_error="invalid SMTP test request", - ) - return templates.TemplateResponse( - request, - "config.html", - context, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - try: - send_smtp_test_email(settings) - except EmailConfigurationError as exc: - logger.warning("SMTP test email rejected due to configuration: %s", exc) - return RedirectResponse( - url="/config?smtp_test=config-error", - status_code=status.HTTP_303_SEE_OTHER, - ) - except EmailDeliveryError as exc: - logger.warning("SMTP test email failed: %s", exc) - return RedirectResponse( - url="/config?smtp_test=failed", - status_code=status.HTTP_303_SEE_OTHER, - ) - - return RedirectResponse( - url="/config?smtp_test=success", - status_code=status.HTTP_303_SEE_OTHER, - ) diff --git a/app/main.py b/app/main.py index 6b42820..267fb6e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,20 @@ +import logging +import os from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger from sqlalchemy.orm import Session from app import models # noqa: F401 -from app.api.routes.auth import router as auth_router -from app.api.routes import pages, status +from app.api.routes.api.config import router as api_config_router +from app.api.routes.api.data import router as api_data_router +from app.api.routes.api.session import router as api_session_router +from app.api.routes import status from app.db import get_session_local from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router @@ -22,6 +27,17 @@ from app.services.config_page import seed_missing_config_from_bootstrap, sync_ap from app.services.public_ip import check_public_ipv4_and_notify from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db +logger = logging.getLogger(__name__) + +_REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _get_spa_dist_dir() -> Path: + env_val = os.environ.get("SPA_DIST_DIR") + if env_val: + return Path(env_val) + return _REPO_ROOT / "frontend" / "dist" + def _run_scheduled_public_ip_check() -> None: session_local = get_session_local() @@ -89,13 +105,47 @@ def create_app() -> FastAPI: app.mount("/static", StaticFiles(directory=static_dir), name="static") app.include_router(status.router) - app.include_router(auth_router) - app.include_router(pages.router) + app.include_router(api_config_router) + app.include_router(api_data_router) + app.include_router(api_session_router) app.include_router(homeassistant_router) app.include_router(location_router) app.include_router(poo_router) app.include_router(public_ip_router) app.include_router(ticktick_router) + + # SPA hosting: mount frontend/dist if it exists and has index.html. + # If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely + # so that pytest stays green with only the API routes registered. + spa_dist = _get_spa_dist_dir() + spa_index = spa_dist / "index.html" + if spa_dist.is_dir() and spa_index.is_file(): + spa_assets = spa_dist / "assets" + if spa_assets.is_dir(): + app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets") + + # Resolve the dist root once so the containment check is fast and consistent. + _spa_root = spa_dist.resolve() + + @app.get("/{full_path:path}", include_in_schema=False) + async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029 + # Explicit 404 for unmatched /api/* — never return index.html for API paths. + if full_path.startswith("api/"): + raise HTTPException(status_code=404, detail="not found") + # Resolve candidate to an absolute path and verify it stays within the SPA + # dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f") + # bypass Starlette's path parameter handling and allow arbitrary file reads. + candidate = (spa_dist / full_path).resolve() + if candidate.is_file() and candidate.is_relative_to(_spa_root): + return FileResponse(candidate) + # For any path outside the dist root, or for SPA client routes that don't + # correspond to a real file, return index.html so the SPA router handles it. + return FileResponse(spa_index) + else: + logger.warning( + "SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist + ) + return app diff --git a/app/schemas/config.py b/app/schemas/config.py new file mode 100644 index 0000000..eab94b1 --- /dev/null +++ b/app/schemas/config.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + + +class ConfigField(BaseModel): + env_name: str + label: str + value: str + secret: bool + input_type: str + configured: bool + + +class ConfigSection(BaseModel): + name: str + fields: list[ConfigField] + + +class ConfigResponse(BaseModel): + sections: list[ConfigSection] + + +class ConfigUpdateRequest(BaseModel): + """Flat mapping of env_name → value, mirroring the existing form semantics.""" + + updates: dict[str, str] + + +class ConfigUpdateResponse(BaseModel): + sections: list[ConfigSection] + + +class SmtpTestResponse(BaseModel): + """Response from POST /api/config/smtp/test.""" + + result: Literal["success", "config-error", "failed"] + message: str diff --git a/app/schemas/data.py b/app/schemas/data.py new file mode 100644 index 0000000..f53864c --- /dev/null +++ b/app/schemas/data.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +# --------------------------------------------------------------------------- +# Location +# --------------------------------------------------------------------------- + + +class LocationRecord(BaseModel): + person: str + datetime: str + latitude: float + longitude: float + altitude: float | None + + +class LocationsResponse(BaseModel): + items: list[LocationRecord] + limit: int + offset: int + + +class LocationUpdateRequest(BaseModel): + """PATCH body for a location record — all fields optional; PK fields excluded.""" + + latitude: float | None = None + longitude: float | None = None + altitude: float | None = None + + +# --------------------------------------------------------------------------- +# Poo +# --------------------------------------------------------------------------- + + +class PooRecord(BaseModel): + timestamp: str + status: str + latitude: float + longitude: float + + +class PooResponse(BaseModel): + items: list[PooRecord] + limit: int + offset: int + + +class PooUpdateRequest(BaseModel): + """PATCH body for a poo record — all fields optional; PK field excluded.""" + + status: str | None = None + latitude: float | None = None + longitude: float | None = None + + +# --------------------------------------------------------------------------- +# Public IP +# --------------------------------------------------------------------------- + + +class PublicIPStateSchema(BaseModel): + id: int + current_ipv4: str + previous_ipv4: str | None + first_seen_at: datetime + last_checked_at: datetime + last_changed_at: datetime | None + last_check_status: str + last_check_error: str | None + last_provider: str | None + + model_config = {"from_attributes": True} + + +class PublicIPHistorySchema(BaseModel): + id: int + ipv4: str + observed_at: datetime + change_type: str + provider: str | None + + model_config = {"from_attributes": True} + + +class PublicIPResponse(BaseModel): + state: PublicIPStateSchema | None + history: list[PublicIPHistorySchema] diff --git a/app/schemas/session.py b/app/schemas/session.py new file mode 100644 index 0000000..6eecaac --- /dev/null +++ b/app/schemas/session.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class SessionUser(BaseModel): + username: str + force_password_change: bool + + +class SessionResponse(BaseModel): + user: SessionUser + csrf_token: str + + +class LoginRequest(BaseModel): + username: str + password: str + + +class PasswordChangeRequest(BaseModel): + current_password: str + new_password: str + confirm_password: str diff --git a/app/services/location.py b/app/services/location.py index b9b5618..e3eb818 100644 --- a/app/services/location.py +++ b/app/services/location.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import insert +from sqlalchemy import delete, insert, select from sqlalchemy.orm import Session from app.models.location import Location @@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None: ) session.execute(stmt) session.commit() + + +def update_location( + session: Session, + person: str, + datetime_pk: str, + *, + latitude: float | None, + longitude: float | None, + altitude: float | None, +) -> Location | None: + """Update non-PK fields of a single location row. + + Returns the updated ORM object, or ``None`` if the PK does not exist. + The caller must not pass PK fields — they are immutable. + Only fields with a non-``None`` value are written; ``altitude`` being + ``None`` in the request means "leave unchanged", not "clear to NULL". + """ + row = session.execute( + select(Location).where( + Location.person == person, + Location.datetime == datetime_pk, + ) + ).scalar_one_or_none() + + if row is None: + return None + + if latitude is not None: + row.latitude = latitude + if longitude is not None: + row.longitude = longitude + if altitude is not None: + row.altitude = altitude + + session.commit() + session.refresh(row) + return row + + +def delete_location(session: Session, person: str, datetime_pk: str) -> bool: + """Delete the single location row identified by its full composite PK. + + Returns ``True`` if exactly one row was deleted, ``False`` if the PK did + not exist (caller should raise 404). The DELETE is scoped to the exact PK + — no batch/truncate path exists. + """ + result = session.execute( + delete(Location).where( + Location.person == person, + Location.datetime == datetime_pk, + ) + ) + session.commit() + return result.rowcount == 1 diff --git a/app/services/poo.py b/app/services/poo.py index 001a009..ad397af 100644 --- a/app/services/poo.py +++ b/app/services/poo.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime, timezone import logging -from sqlalchemy import desc, insert, select +from sqlalchemy import delete, desc, insert, select from sqlalchemy.orm import Session from app.config import Settings @@ -74,6 +74,53 @@ def record_poo( logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc) +def update_poo_record( + session: Session, + timestamp_pk: str, + *, + status: str | None, + latitude: float | None, + longitude: float | None, +) -> PooRecord | None: + """Update non-PK fields of a single poo record row. + + Returns the updated ORM object, or ``None`` if the PK does not exist. + The ``timestamp`` PK is immutable and must not be passed as an update field. + Only fields with a non-``None`` value are written. + """ + row = session.execute( + select(PooRecord).where(PooRecord.timestamp == timestamp_pk) + ).scalar_one_or_none() + + if row is None: + return None + + if status is not None: + row.status = status + if latitude is not None: + row.latitude = latitude + if longitude is not None: + row.longitude = longitude + + session.commit() + session.refresh(row) + return row + + +def delete_poo_record(session: Session, timestamp_pk: str) -> bool: + """Delete the single poo record row identified by its PK. + + Returns ``True`` if exactly one row was deleted, ``False`` if the PK did + not exist (caller should raise 404). The DELETE is scoped to the exact PK + — no batch/truncate path exists. + """ + result = session.execute( + delete(PooRecord).where(PooRecord.timestamp == timestamp_pk) + ) + session.commit() + return result.rowcount == 1 + + def get_latest_poo_record(session: Session) -> LatestPooRecord | None: stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1) record = session.execute(stmt).scalar_one_or_none() diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index e5c583f..0000000 --- a/app/templates/base.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - {% block title %}{{ app_name }}{% endblock %} - - - - -
- {% block content %}{% endblock %} -
- - - diff --git a/app/templates/config.html b/app/templates/config.html deleted file mode 100644 index 0fb3f70..0000000 --- a/app/templates/config.html +++ /dev/null @@ -1,139 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Config · {{ app_name }}{% endblock %} - -{% block content %} -
-

Configuration

-

Config

- - {% if force_password_change %} -
- 首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。 -
- {% endif %} - - {% if password_change_error %} -
{{ password_change_error }}
- {% endif %} - - {% if config_error %} -
{{ config_error }}
- {% endif %} - - {% if config_saved %} -
config saved to the app database. Some changes may require an app restart.
- {% endif %} - - {% if ticktick_oauth_error %} -
{{ ticktick_oauth_error }}
- {% endif %} - - {% if ticktick_oauth_notice %} -
{{ ticktick_oauth_notice }}
- {% endif %} - - {% if smtp_test_error %} -
{{ smtp_test_error }}
- {% endif %} - - {% if smtp_test_notice %} -
{{ smtp_test_notice }}
- {% endif %} - -
-
-
当前用户
-
admin
-
-
- -
-

Change Password

-
- - - - - - - - - -
-
- -
-

Config

-
- - - {% for section in config_sections %} -
- {{ section.name }} - {% for field in section.fields %} - - {% endfor %} - - {% if section.name == "TickTick" %} -
-
-

TickTick OAuth

-

Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}

- {% if ticktick_oauth_ready %} -

Use the saved TickTick client settings to start the authorization flow.

- {% else %} -

Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.

- {% endif %} -
- {% if ticktick_oauth_ready %} - Authorize TickTick - {% else %} - Authorize TickTick - {% endif %} -
- {% endif %} - - {% if section.name == "SMTP" %} -
-
-

SMTP Test Email

-

Save the SMTP settings first, then send a simple plaintext test email to the configured recipient.

-
- {% if smtp_test_ready %} - - {% else %} - Send SMTP Test - {% endif %} -
- {% endif %} -
- {% endfor %} - - -
-
- -
- - -
-
-{% endblock %} diff --git a/app/templates/home.html b/app/templates/home.html deleted file mode 100644 index 63ef3aa..0000000 --- a/app/templates/home.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} - -{% block title %}{{ app_name }}{% endblock %} - -{% block content %} -
-

Python Rewrite Skeleton

-

{{ app_name }}

-

- 这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、 - 测试、模板和容器化基础,不包含业务逻辑迁移。 -

-
-
-
运行环境
-
{{ app_env }}
-
-
-
健康检查
-
/status
-
-
-
OpenAPI
-
/docs
-
-
-
登录
-
/login
-
-
-
Notion
-
{{ notion_status }}
-
-
-
-{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html deleted file mode 100644 index 8dcc2d7..0000000 --- a/app/templates/login.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends "base.html" %} - -{% block title %}登录 · {{ app_name }}{% endblock %} - -{% block content %} -
-

Authentication

-

登录

-

- 登录成功后会进入受保护的 config 页面。 -

- - {% if error_message %} -
{{ error_message }}
- {% endif %} - -
- - - - - - - -
-
-{% endblock %} diff --git a/dev-requirements.txt b/dev-requirements.txt index 1d79e6a..adcc7f0 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -53,14 +53,10 @@ idna==3.11 # httpx iniconfig==2.3.0 # via pytest -jinja2==3.1.6 - # via -r requirements.in mako==1.3.11 # via alembic markupsafe==3.0.3 - # via - # jinja2 - # mako + # via mako packaging==26.1 # via # build diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..468a39d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,28 @@ +# Local dev override — use explicitly: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build +# Isolated from the production stack so both can run on this host at once: +# - distinct compose project name (separate network/grouping) +# - distinct container names (-dev suffix; Docker rejects duplicate names) +# - distinct image tag (local build doesn't clobber the prod :latest tag) +name: home-automation-dev + +services: + migration: + build: . + image: home-automation:dev + container_name: home-automation-migration-dev + environment: + # In-container path for the mounted ./data volume (./data -> /app/data). + # Overrides the host-absolute APP_DATABASE_URL in .env for local compose runs. + APP_DATABASE_URL: "sqlite:////app/data/app.db" + + app: + build: . + image: home-automation:dev + container_name: home-automation-app-dev + # Publish on 8001 for dev. `!override` REPLACES the base ports list instead of + # appending to it, so the dev stack does NOT also bind the production 8881. + ports: !override + - "127.0.0.1:8001:8000" + environment: + APP_DATABASE_URL: "sqlite:////app/data/app.db" diff --git a/docker-compose.override.yml b/docker-compose.override.yml deleted file mode 100644 index 78f2dd7..0000000 --- a/docker-compose.override.yml +++ /dev/null @@ -1,6 +0,0 @@ -services: - migration: - build: . - - app: - build: . \ No newline at end of file diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 41d7239..8aabd28 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -29,10 +29,8 @@ - 通用依赖注入 - `api/` - HTTP routes - - 当前已迁入 `/login`、`/logout`、`/admin` - - 当前已迁入 `GET /public-ip/check` - - 当前已迁入 `POST /homeassistant/publish` 第一版入口 - - 当前已迁入 `POST /poo/record` 与 `GET /poo/latest` + - `api/routes/api/`:JSON API(`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD + - 裸 ingestion 端点:`GET /public-ip/check`、`POST /homeassistant/publish`、`POST /poo/record`、`GET /poo/latest`、TickTick OAuth 等 - `models/` - SQLAlchemy models - 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db` 中 @@ -46,8 +44,6 @@ - `integrations/` - 外部系统适配层 - 当前已迁入 Home Assistant outbound adapter -- `templates/` - - Jinja2 模板 - `static/` - 极简静态资源 @@ -63,15 +59,26 @@ pytest 测试目录。后续可以在这里自然扩展: - mock tests - integration tests +### `frontend/` + +React SPA 前端(M2 引入)。Vite + React + TypeScript + Mantine,由 FastAPI 同源托管。 + +- `src/`:React 源码 +- `src/api/`:由 `openapi/openapi.json` 生成的类型化 client(`schema.d.ts`)+ fetch 封装 +- `dist/`:`npm run build` 产物,由 FastAPI 的 `SPA_DIST_DIR` 挂载并对非 `/api` 路径做 fallback + ### `scripts/` -辅助脚本目录。当前包含 OpenAPI 导出脚本。 +辅助脚本目录。当前包含 OpenAPI 导出脚本(`export_openapi.py`)与数据层辅助脚本。 + +### `openapi/` + +OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),由 `python scripts/export_openapi.py` 生成,纳入版本控制。前端 codegen 以此为契约源。 ## 当前约束 -- 当前只搭骨架,不迁业务逻辑 - 当前数据库继续使用 SQLite -- 当前不引入前后端分离 +- ~~当前不引入前后端分离~~ **已退役(M2)**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管 - 当前不设计 Notion 模块 - 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象 diff --git a/docs/design/m2-frontend-v2.md b/docs/design/m2-frontend-v2.md index 4491c72..a44358a 100644 --- a/docs/design/m2-frontend-v2.md +++ b/docs/design/m2-frontend-v2.md @@ -122,7 +122,7 @@ > 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。 ### M2-T01 — config JSON API -- **Status**: `todo` · **Depends**: none(M1 完成后) +- **Status**: `done` · **Depends**: none(M1 完成后) - **Context**: 把 `config_page` 的读写能力暴露成 JSON,复用现有 service,不重写业务逻辑。 - **Files**: `create app/api/routes/api/config.py`、`create app/schemas/config.py`;`modify app/main.py`(注册路由);`create tests/test_api_config.py` - **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`;session 保护;secret 不回显、留空保留旧值语义照搬。 @@ -134,7 +134,7 @@ - **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。 ### M2-T02 — session / auth JSON API -- **Status**: `todo` · **Depends**: none +- **Status**: `done` · **Depends**: none - **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。 - **Files**: `create app/api/routes/api/session.py`、`app/schemas/session.py`;`modify app/main.py`;`create tests/test_api_session.py` - **Steps**: `GET /api/session`(401 或 user+csrf)、`POST /api/auth/login`、`POST /api/auth/logout`、`POST /api/auth/password`,复用 `app/services/auth.py`。 @@ -147,7 +147,7 @@ - **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env`、`SameSite=Lax`;密码仍 Argon2,不明文。 ### M2-T03 — 数据读取 API(locations / poo / public-ip) -- **Status**: `todo` · **Depends**: none +- **Status**: `done` · **Depends**: none - **Files**: `create app/api/routes/api/data.py`、`app/schemas/data.py`;`modify app/main.py`;`create tests/test_api_data.py` - **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`(state + history);session 保护;查询参数有上限防全表导出。 - **Acceptance**: @@ -157,7 +157,7 @@ - **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。 ### M2-T04 — 记录 CRUD API(修正 / 删除) -- **Status**: `todo` · **Depends**: M2-T03 +- **Status**: `done` · **Depends**: M2-T03 - **Files**: `modify app/api/routes/api/data.py`、`app/services/location.py`、`app/services/poo.py`;`create tests/test_api_record_crud.py` - **Steps**: `PATCH`/`DELETE` location(PK person+datetime)与 poo(PK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。 - **Acceptance**: @@ -169,7 +169,7 @@ - **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。 ### M2-T05 — SMTP 测试 / 动作类 JSON API -- **Status**: `todo` · **Depends**: M2-T01 +- **Status**: `done` · **Depends**: M2-T01 - **Files**: `modify app/api/routes/api/config.py`;`modify tests/test_api_config.py` - **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。 - **Acceptance**: @@ -177,7 +177,7 @@ - [ ] 校验闸门全绿。 ### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]` -- **Status**: `todo` · **Depends**: M2-T01..T05(OpenAPI 已稳定) +- **Status**: `done` · **Depends**: M2-T01..T05(OpenAPI 已稳定) - **Context**: 建 `frontend/` 工程与类型化 client 流水线,这是后续所有前端任务的地基。 - **Files**: `create frontend/`(Vite+React+TS 脚手架、`package.json`、`tsconfig.json`、eslint、vitest、`.gitignore`)、`frontend/src/api/`(codegen 产物 + fetch 封装,自动注入 `X-CSRF-Token`)、`frontend/README.md`、`npm run codegen` 脚本 - **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。 @@ -188,24 +188,24 @@ - **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。 ### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密) -- **Status**: `todo` · **Depends**: M2-T06 +- **Status**: `done` · **Depends**: M2-T06 - **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。 ### M2-T08 — 配置 UI(取代 Jinja config 页) -- **Status**: `todo` · **Depends**: M2-T06 +- **Status**: `done` · **Depends**: M2-T06 - **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。 ### M2-T09 — 数据可视化 UI(热力图为主的地图) -- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03) +- **Status**: `done` · **Depends**: M2-T06(数据来自 T03) - **Context**: 接管 Grafana 原职责,且**首页主视图就是这张地图**。优先级:**① 热力图(最重要)② 时间范围选择器(必须)③ 散点点位(辅助,主要服务编辑/删除)**。location:去过哪的密度;poo:狗最爱在哪拉。 - **Acceptance**: 首页渲染热力图(location / poo);**时间范围选择器生效、只取窗口内数据**(不拉全量);散点层可切换、点选某点可进入编辑/删除(接 T10/T04);location 点多时聚合;响应式(手机浏览器可用);前端闸门全绿。 ### M2-T10 — 记录管理 UI(按需展示 + 增删改) -- **Status**: `todo` · **Depends**: M2-T06(CRUD 来自 T04) +- **Status**: `done` · **Depends**: M2-T06(CRUD 来自 T04) - **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。 ### M2-T11 — FastAPI 托管 SPA + 移除 Jinja -- **Status**: `todo` · **Depends**: M2-T07, T08, T09, T10 +- **Status**: `done` · **Depends**: M2-T07, T08, T09, T10 - **Files**: `modify app/main.py`(挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`);`delete app/templates/`、`app/api/routes/pages.py`(功能对齐后);`modify tests`(移除 Jinja 页面测试,新增 SPA fallback 测试) - **Acceptance**: - [ ] `/config` 等路径返回 SPA(`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。 @@ -214,7 +214,7 @@ - **Reviewer**: fallback 不拦截 `/api`、`/docs`、`/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。 ### M2-T12 — 多阶段 Dockerfile + CI/compose -- **Status**: `todo` · **Depends**: M2-T11 +- **Status**: `done` · **Depends**: M2-T11 - **Files**: `modify Dockerfile`(node build 阶段 → 拷 `dist` 进 python 镜像);`modify .github/workflows/*`(加前端 build/lint/typecheck);`modify tests/test_deployment.py`(镜像断言更新) - **Acceptance**: - [ ] 镜像构建成功且运行镜像不含 node 运行时。 @@ -222,7 +222,7 @@ - [ ] 校验闸门全绿。 ### M2-T13 — 文档 + OpenAPI 收尾 -- **Status**: `todo` · **Depends**: M2-T12 +- **Status**: `done` · **Depends**: M2-T12 - **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2;`openapi/` 已同步入库。 --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 36771e6..62f65e8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,7 +35,7 @@ | 里程碑 | 主题 | 一句话 | | --- | --- | --- | | **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana | -| **M2** | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 | +| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 | | **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 | 排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工。 @@ -101,7 +101,7 @@ --- -## M2 — 前端 v2(React SPA) +## M2 — 前端 v2(React SPA)✅ 已完成 ### 目标 @@ -125,9 +125,11 @@ ### 鉴权边界(与 M3 衔接) -- 现在那个“裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。 +- 现在那个”裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。 - M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。 +> **M2 已完成**(M2-T01 至 M2-T13 全部 done)。Jinja 模板已移除,React SPA 同源托管,多阶段 Docker 构建通过,所有校验闸门绿。 + --- ## M3 — 开放与移动端(远期试水) @@ -146,3 +148,23 @@ - 移动端是这一阶段最远期、最不确定的部分。 - token 主要是移动端的前置条件;Web 端 React 用现有 session cookie 即可,不需要为它提前引入 token。 + +## Future Ideas(暂不排期,想到先记下) + +> 这里收集**还没排进里程碑**的想法。不是承诺、也没有先后顺序;想做时再从这里捞出来细化成 `docs/design/` 的任务卡。**明确不开 M2.5**——下列条目一律先躺在 Future Ideas,之后再说。 + +### TOTP 二次验证(Dashboard 加固) + +**动机**:M2 之后多了一个 Web Dashboard。它虽有单 admin 密码保护,但**大概率会暴露在公网**上,只靠密码这一层不够。给登录再叠一层 **TOTP(基于时间的一次性密码,RFC 6238)** 作为第二因子,做纵深防御。 + +**范围(粗略,待细化)**: + +- 在现有单 admin(Argon2 + server-side session)登录之上,叠加 TOTP 第二步:密码校验通过后再验 6 位动态码,通过才发 session cookie。 +- 首次启用时生成 TOTP secret,给出可导入 Authenticator 的二维码 / 可手输密钥;同时生成一组一次性**恢复码(recovery codes)**。 + +**运维 / 命令行要求(关键,实现时必须满足)**: + +1. **忘记密码**:不需要任何 Web 端“找回密码”流程——直接在命令行里重置 admin 密码即可(沿用现有 CLI 思路)。 +2. **TOTP 重置 / 恢复**:必须提供**命令行重置入口**。要覆盖最坏情况——**连恢复码(restore key)都丢了**,也能纯靠 CLI 把 TOTP 关掉 / 重新发放新的 secret,从而恢复登录。即:**CLI 是不依赖任何已存恢复凭据的最终逃生通道**,不能出现“密钥丢了就彻底锁死”的死角。 + +**先不做**:本条仅记入 Future Ideas,不进 M2.5、不排期;之后再细化为 design 任务卡。 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..faa15d6 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +dist-ssr/ +*.local +.env +.env.* +!.env.example diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..38072b2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,209 @@ +# Home Automation — Frontend + +React SPA for the home-automation backend. Built with Vite + React 18 + TypeScript. +Scaffolded in M2-T06; feature pages filled in by T07–T10. + +## Stack + +| Layer | Library | Version | +|---|---|---| +| Build | Vite | 6.x | +| UI framework | React | 18.x | +| Language | TypeScript | 5.x | +| Component library | Mantine | 7.x | +| Data fetching | TanStack Query | 5.x | +| Routing | react-router-dom | 6.x | +| API client codegen | openapi-typescript | 7.x | +| API client runtime | openapi-fetch | 0.17.x | +| Testing | Vitest + @testing-library/react | 4.x / 14.x | + +## npm Scripts + +| Command | What it does | +|---|---| +| `npm run dev` | Start Vite dev server (with backend proxy — see below) | +| `npm run build` | `tsc -b && vite build` — type-check then build to `dist/` | +| `npm run preview` | Serve the built `dist/` locally | +| `npm run lint` | ESLint (flat config, React + TypeScript rules) | +| `npm run typecheck` | `tsc --noEmit` — type-check without emitting files | +| `npm run test` | Vitest (run once, no watch) | +| `npm run codegen` | Regenerate `src/api/schema.d.ts` from `../openapi/openapi.json` | + +All frontend gates must pass before any task is considered done: +```bash +npm run codegen +npm run lint +npm run typecheck +npm run test +npm run build # must produce dist/ +``` + +## Directory Structure + +``` +frontend/ +├── index.html Vite entry HTML +├── vite.config.ts Vite + Vitest config; dev proxy +├── tsconfig.json References tsconfig.app.json + tsconfig.node.json +├── tsconfig.app.json App source TS config (strict, react-jsx) +├── tsconfig.node.json Vite config TS config +├── eslint.config.js Flat ESLint config (React + TypeScript rules) +├── package.json Dependencies + npm scripts +├── package-lock.json Lockfile (committed; CI uses npm ci) +└── src/ + ├── main.tsx Entry point; mounts into #root + ├── App.tsx Provider stack + route tree (MantineProvider → QueryClient → Router → SessionProvider) + ├── vite-env.d.ts /// for CSS imports + ├── test-setup.ts Vitest global setup (@testing-library/jest-dom) + ├── api/ + │ ├── schema.d.ts AUTO-GENERATED from openapi/openapi.json (committed) + │ ├── client.ts openapi-fetch client + CSRF/cookie/401 middleware + │ └── csrf.ts Module-level CSRF token holder (setCsrfToken / getCsrfToken) + ├── auth/ + │ ├── SessionProvider.tsx TanStack Query against GET /api/session; exposes useSession() + │ └── ProtectedRoute.tsx Redirects to /login when unauthenticated + └── pages/ + ├── LoginPage.tsx Placeholder → T07 builds the real form + ├── HomePage.tsx Placeholder → T09 builds the map/heatmap view + └── ConfigPage.tsx Placeholder → T08 builds the config editor +``` + +## Dev Proxy (local development) + +`npm run dev` starts Vite on port 5173. The Vite config proxies API/auth paths +to the FastAPI backend running on port 8000: + +| Proxied path | Backend URL | +|---|---| +| `/api/*` | `http://localhost:8000` | +| `/login` | `http://localhost:8000` | +| `/logout` | `http://localhost:8000` | +| `/static/*` | `http://localhost:8000` | +| `/docs` | `http://localhost:8000` | +| `/openapi.json` | `http://localhost:8000` | + +To develop locally: +1. Start the backend: `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` +2. Start the frontend: `cd frontend && npm run dev` +3. Open `http://localhost:5173` — the app proxies all API calls to the backend. + +Since the dev server proxies the session cookie path, auth flows work exactly as +they would in the deployed (same-origin) setup. + +## Adding a New Page + Typed Query + +This is the pattern every task T07–T10 follows to wire up a real page: + +### 1. Run codegen (if the OpenAPI contract changed) + +```bash +npm run codegen +``` + +The generated `src/api/schema.d.ts` is committed to the repo. CI enforces that +the file is in sync with `openapi/openapi.json` via: +```bash +npm run codegen && git diff --exit-code frontend/src/api/schema.d.ts +``` + +### 2. Import the typed client + +```typescript +// src/pages/SomePage.tsx +import apiClient from '../api/client' +``` + +### 3. Write a typed TanStack Query + +```typescript +import { useQuery } from '@tanstack/react-query' +import apiClient from '../api/client' + +function usePooRecords(limit = 100) { + return useQuery({ + queryKey: ['poo', { limit }], + queryFn: async () => { + const res = await apiClient.GET('/api/poo', { params: { query: { limit } } }) + // res.data is typed as PooResponse | undefined + // On non-2xx the middleware throws ApiError; TanStack Query catches it. + return res.data + }, + }) +} +``` + +The `params.query` and `params.path` objects are fully typed from `schema.d.ts`. +TypeScript will error if you pass unknown query params or mistype a path param. + +### 4. Write a typed mutation (write request) + +```typescript +import { useMutation, useQueryClient } from '@tanstack/react-query' +import apiClient from '../api/client' + +function useDeletePoo() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (timestamp: string) => + apiClient.DELETE('/api/poo/{timestamp}', { + params: { path: { timestamp } }, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }), + }) +} +``` + +The middleware (`src/api/client.ts`) automatically injects the `X-CSRF-Token` header +on all non-GET/HEAD requests (sourced from `getCsrfToken()`). You do not need to +handle CSRF manually in page code. + +### 5. Add the route in App.tsx + +```typescript +// App.tsx +import { SomePage } from './pages/SomePage' + +// Inside : +} /> +// or, if protected: + + + + } +> + } /> + +``` + +## OpenAPI codegen + CI sync rule + +`src/api/schema.d.ts` is committed to the repository (not gitignored). + +**Rule**: whenever `openapi/openapi.json` changes (any backend task that modifies +a route or schema), CI must run: +```bash +cd frontend && npm run codegen +git diff --exit-code frontend/src/api/schema.d.ts +``` +If the file has changed but the new version was not committed, CI fails. + +To update manually after a backend change: +```bash +cd frontend +npm run codegen +git add src/api/schema.d.ts +git commit -m "M2-Txx: update generated OpenAPI types" +``` + +## Production Build + +The production build (`npm run build`) writes static files to `frontend/dist/`. +In the deployed setup (M2-T11 onwards), FastAPI serves `dist/` as a static +directory and falls back to `dist/index.html` for all non-`/api` paths, +enabling client-side routing with deep links. + +The multi-stage Dockerfile (M2-T12) builds the frontend in a Node container and +copies only `dist/` into the Python image — the production image does not +contain Node or npm. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..8f51fb2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactPlugin from 'eslint-plugin-react' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist', 'src/api/schema.d.ts'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + react: reactPlugin, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react/react-in-jsx-scope': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, + }, +) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6d42b32 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Home Automation + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6aa9396 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,7289 @@ +{ + "name": "home-automation-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "home-automation-frontend", + "version": "0.0.0", + "dependencies": { + "@mantine/core": "^7.17.8", + "@mantine/hooks": "^7.17.8", + "@tanstack/react-query": "^5.101.0", + "@types/leaflet": "^1.9.21", + "@types/leaflet.markercluster": "^1.5.6", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", + "openapi-fetch": "^0.17.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-feather": "^2.0.10", + "react-leaflet": "^4.2.1", + "react-router-dom": "^6.30.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9.39.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "jsdom": "^29.1.1", + "openapi-typescript": "^7.13.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.61.0", + "vite": "^6.4.3", + "vitest": "^4.1.8" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz", + "integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@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/@mantine/core": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.17.8.tgz", + "integrity": "sha512-42sfdLZSCpsCYmLCjSuntuPcDg3PLbakSmmYfz5Auea8gZYLr+8SS5k647doVu0BRAecqYOytkX2QC5/u/8VHw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.28", + "clsx": "^2.1.1", + "react-number-format": "^5.4.3", + "react-remove-scroll": "^2.6.2", + "react-textarea-autosize": "8.5.9", + "type-fest": "^4.27.0" + }, + "peerDependencies": { + "@mantine/hooks": "7.17.8", + "react": "^18.x || ^19.x", + "react-dom": "^18.x || ^19.x" + } + }, + "node_modules/@mantine/hooks": { + "version": "7.17.8", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.17.8.tgz", + "integrity": "sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.x || ^19.x" + } + }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@redocly/openapi-core/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@redocly/openapi-core/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/@redocly/openapi-core/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@testing-library/react/node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/react/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "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/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, + "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.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "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/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "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-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/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/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "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/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "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/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "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/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", + "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "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": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.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/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-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/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-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-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "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-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-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-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "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/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/leaflet.heat": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/leaflet.heat/-/leaflet.heat-0.2.0.tgz", + "integrity": "sha512-Cd5PbAA/rX3X3XKxfDoUGi9qp78FyhWYurFg3nsfhntcM/MCNK08pRkf4iEenO1KNqwVPKCmkyktjW3UD+h9bQ==" + }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.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/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/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, + "node_modules/openapi-typescript/node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=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/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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/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/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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-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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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-feather": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/react-feather/-/react-feather-2.0.10.tgz", + "integrity": "sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/react-number-format": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.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/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "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/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/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", + "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/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/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "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/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..25731b0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,49 @@ +{ + "name": "home-automation-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "codegen": "openapi-typescript ../openapi/openapi.json -o ./src/api/schema.d.ts" + }, + "dependencies": { + "@mantine/core": "^7.17.8", + "@mantine/hooks": "^7.17.8", + "@tanstack/react-query": "^5.101.0", + "@types/leaflet": "^1.9.21", + "@types/leaflet.markercluster": "^1.5.6", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", + "openapi-fetch": "^0.17.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-feather": "^2.0.10", + "react-leaflet": "^4.2.1", + "react-router-dom": "^6.30.4" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9.39.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "jsdom": "^29.1.1", + "openapi-typescript": "^7.13.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.61.0", + "vite": "^6.4.3", + "vitest": "^4.1.8" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..16e195f --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,219 @@ +/** + * App — top-level provider stack and route tree. + * + * Provider order (outermost first): + * MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes + * + * Route tree: + * /login → LoginPage (public) + * /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate) + * / → ProtectedRoute → AppLayout → HomePage (T09) + * /config → ProtectedRoute → AppLayout → ConfigPage (T08) + * + * AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07). + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom' +import { + MantineProvider, + Group, + ActionIcon, + Tooltip, + useMantineColorScheme, + useComputedColorScheme, +} from '@mantine/core' +import { List, Settings, Sun, Moon, LogOut } from 'react-feather' + +// Mantine requires its CSS to be imported once. +import '@mantine/core/styles.css' + +import { SessionProvider } from './auth/SessionProvider' +import { ProtectedRoute } from './auth/ProtectedRoute' +import { LoginPage } from './pages/LoginPage' +import { HomePage } from './pages/HomePage' +import { ConfigPage } from './pages/ConfigPage' +import { RecordsPage } from './pages/RecordsPage' +import { ChangePasswordPage } from './pages/ChangePasswordPage' +import apiClient from './api/client' +import { useQueryClient } from '@tanstack/react-query' + +// --------------------------------------------------------------------------- +// TanStack Query client (singleton, created outside render to avoid re-creation) +// --------------------------------------------------------------------------- + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Don't retry on 4xx — we handle 401 in the middleware + retry: (failureCount, error) => { + if (error instanceof Error && 'status' in error) { + const status = (error as unknown as { status: number }).status + if (status >= 400 && status < 500) return false + } + return failureCount < 2 + }, + }, + }, +}) + +// --------------------------------------------------------------------------- +// Logout button component (needs navigate + queryClient hooks, so it's a component) +// --------------------------------------------------------------------------- + +function LogoutButton() { + const navigate = useNavigate() + const qc = useQueryClient() + + async function handleLogout() { + try { + await apiClient.POST('/api/auth/logout') + } catch { + // Ignore errors on logout — we clear the session regardless. + } + // Invalidate session so SessionProvider becomes unauthenticated. + await qc.invalidateQueries({ queryKey: ['session'] }) + navigate('/login', { replace: true }) + } + + return ( + + + + + + ) +} + +// --------------------------------------------------------------------------- +// Dark-mode toggle (sits next to the gear / settings icon) +// --------------------------------------------------------------------------- + +function ColorSchemeToggle() { + const { setColorScheme } = useMantineColorScheme() + const computed = useComputedColorScheme('light', { getInitialValueInEffect: true }) + const isDark = computed === 'dark' + return ( + + setColorScheme(isDark ? 'light' : 'dark')} + data-testid="color-scheme-toggle" + > + {isDark ? : } + + + ) +} + +// --------------------------------------------------------------------------- +// App shell layout (used by all protected pages) +// --------------------------------------------------------------------------- + +function AppLayout() { + return ( +
+ {/* Top nav */} + + + {/* Page content */} +
+ +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Root app +// --------------------------------------------------------------------------- + +export default function App() { + return ( + + + + + + {/* Public routes */} + } /> + + {/* Forced password change — protected (must be logged in) but outside AppLayout */} + + + + } + /> + + {/* Protected routes — all nested under AppLayout */} + + + + } + > + } /> + } /> + } /> + + + + + + + ) +} diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts new file mode 100644 index 0000000..419ad93 --- /dev/null +++ b/frontend/src/api/client.test.ts @@ -0,0 +1,62 @@ +/** + * csrfMiddleware 401-handling regression tests. + * + * Bug: clicking Logout (or landing on /login) flooded GET /api/session with 401s + * and the page hung instead of returning to the login screen. + * + * Root cause: the middleware redirected on EVERY 401, including the session + * probe's own 401. The redirect invalidated the ['session'] query, which + * refetched GET /api/session, which 401'd, which redirected again → an infinite + * refetch loop. These tests pin the fix: the session probe and the login + * endpoint own their 401s (no redirect); any other endpoint's 401 still + * redirects (session expired mid-use). + * + * We call onResponse() directly (rather than going through apiClient.GET) so the + * test exercises the exact 401 branch without the singleton's relative baseUrl, + * which has no absolute origin to resolve against under jsdom. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Middleware } from 'openapi-fetch' +import { csrfMiddleware, registerLoginRedirect } from './client' + +type OnResponse = NonNullable +type OnResponseParams = Parameters[0] + +/** Build the minimal onResponse params for the given schema path + response. */ +function params(schemaPath: string, response: Response): OnResponseParams { + return { schemaPath, response, request: new Request('http://test.local' + schemaPath) } as OnResponseParams +} + +function response401(): Response { + return new Response(JSON.stringify({ detail: 'unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) +} + +const onResponse = csrfMiddleware.onResponse as OnResponse + +describe('csrfMiddleware 401 redirect (session-flood regression)', () => { + const redirect = vi.fn() + + beforeEach(() => { + redirect.mockReset() + registerLoginRedirect(redirect) + }) + + it('does NOT redirect when GET /api/session returns 401 (probe owns its 401)', async () => { + await onResponse(params('/api/session', response401())) + expect(redirect).not.toHaveBeenCalled() + }) + + it('does NOT redirect when POST /api/auth/login returns 401 (bad credentials)', async () => { + await onResponse(params('/api/auth/login', response401())) + expect(redirect).not.toHaveBeenCalled() + }) + + it('redirects when a normal endpoint returns 401 (session expired mid-use)', async () => { + await onResponse(params('/api/locations', response401())) + expect(redirect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..f018b29 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,125 @@ +/** + * Typed API client built on openapi-fetch + generated schema.d.ts. + * + * Middleware contract (orchestrator-decisions.md §11): + * 1. Always send cookies (credentials: "include"; same-origin auto-sends but explicit is clear). + * 2. Non-GET/HEAD requests inject X-CSRF-Token from the csrf holder. + * Exception: POST /api/auth/login skips injection (unauthenticated endpoint). + * 3. 401 responses → clear session state + navigate to /login. + * 4. Other non-2xx responses → throw an ApiError carrying the parsed JSON body, + * so callers (e.g. SMTP test) can inspect body.result. + */ + +import createClient, { type Middleware } from 'openapi-fetch' +import type { paths } from './schema.d.ts' +import { getCsrfToken } from './csrf' + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/** Error thrown for non-2xx, non-401 responses. Carries the parsed JSON body. */ +export class ApiError extends Error { + constructor( + public readonly status: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly body: any, + ) { + super(`API error ${status}`) + this.name = 'ApiError' + } +} + +// --------------------------------------------------------------------------- +// Internal navigation helper (avoids React-router import at module level) +// --------------------------------------------------------------------------- + +let _navigateToLogin: (() => void) | null = null + +/** + * Register a callback that the middleware calls on 401. + * SessionProvider calls this during its setup. + */ +export function registerLoginRedirect(fn: () => void): void { + _navigateToLogin = fn +} + +// --------------------------------------------------------------------------- +// CSRF middleware +// --------------------------------------------------------------------------- + +const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) +const LOGIN_PATH = '/api/auth/login' + +/** + * Endpoints where a 401 is an EXPECTED, locally-handled outcome and must NOT + * trigger the global login redirect: + * - GET /api/session — the session probe; 401 means "not logged in", handled + * by SessionProvider's queryFn (returns null → unauthenticated state). + * - POST /api/auth/login — bad-credentials check; 401 handled by LoginPage. + * + * Redirecting on these would invalidate the session query, which refetches + * /api/session, which 401s, which redirects again → an infinite loop that + * floods GET /api/session after logout and on the login page. + */ +const SESSION_PATH = '/api/session' +const NO_REDIRECT_ON_401 = new Set([SESSION_PATH, LOGIN_PATH]) + +export const csrfMiddleware: Middleware = { + async onRequest({ request }) { + // Always include cookies (same-origin; explicit for clarity) + // Note: credentials is set at client level; this is belt-and-suspenders doc. + + const method = request.method.toUpperCase() + const url = new URL(request.url) + + if (WRITE_METHODS.has(method) && url.pathname !== LOGIN_PATH) { + const token = getCsrfToken() + if (token) { + request.headers.set('X-CSRF-Token', token) + } + } + + return request + }, + + async onResponse({ schemaPath, response }) { + if (response.status === 401) { + // The session probe and the login endpoint own their 401s (see + // NO_REDIRECT_ON_401). For any OTHER endpoint, a 401 means the session + // expired mid-use → redirect to /login. Crucially, NOT redirecting on the + // session probe breaks the refetch→401→redirect→refetch flood loop. + if (!NO_REDIRECT_ON_401.has(schemaPath) && _navigateToLogin) { + _navigateToLogin() + } + // Return the original response so callers can handle 401 if needed. + return response + } + + if (!response.ok) { + // Parse body and throw; caller can catch ApiError and read .body + let body: unknown + try { + body = await response.clone().json() + } catch { + body = null + } + throw new ApiError(response.status, body) + } + + return response + }, +} + +// --------------------------------------------------------------------------- +// Client instance +// --------------------------------------------------------------------------- + +const apiClient = createClient({ + baseUrl: '/', + credentials: 'include', +}) + +apiClient.use(csrfMiddleware) + +export default apiClient diff --git a/frontend/src/api/csrf.test.ts b/frontend/src/api/csrf.test.ts new file mode 100644 index 0000000..2c90fc8 --- /dev/null +++ b/frontend/src/api/csrf.test.ts @@ -0,0 +1,35 @@ +/** + * Smoke tests for the CSRF token holder. + * These run in isolation (no DOM, no React) and validate the module contract. + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import { setCsrfToken, getCsrfToken } from './csrf' + +describe('csrf holder', () => { + beforeEach(() => { + // Reset to empty between tests by setting empty string + setCsrfToken('') + }) + + it('returns empty string before any token is set', () => { + expect(getCsrfToken()).toBe('') + }) + + it('stores and returns the token that was set', () => { + setCsrfToken('test-token-abc123') + expect(getCsrfToken()).toBe('test-token-abc123') + }) + + it('overwrites a previously set token', () => { + setCsrfToken('first') + setCsrfToken('second') + expect(getCsrfToken()).toBe('second') + }) + + it('can be reset to empty', () => { + setCsrfToken('some-token') + setCsrfToken('') + expect(getCsrfToken()).toBe('') + }) +}) diff --git a/frontend/src/api/csrf.ts b/frontend/src/api/csrf.ts new file mode 100644 index 0000000..acf2d7f --- /dev/null +++ b/frontend/src/api/csrf.ts @@ -0,0 +1,23 @@ +/** + * Module-level CSRF token holder. + * + * The token is populated by SessionProvider after a successful GET /api/session. + * The fetch client middleware reads it on every non-GET/HEAD request. + * + * Per the project CSRF contract (m2-frontend-v2.md §3.2, orchestrator-decisions.md §3): + * - Server checks presence/non-empty only, does NOT validate the value. + * - Sending an empty-string or stale value will result in a 403; callers must + * ensure setCsrfToken() is called before issuing write requests. + */ + +let _csrfToken = '' + +/** Store the CSRF token returned by GET /api/session. */ +export function setCsrfToken(token: string): void { + _csrfToken = token +} + +/** Return the current CSRF token (may be empty string if not yet set). */ +export function getCsrfToken(): string { + return _csrfToken +} diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts new file mode 100644 index 0000000..32b6c4a --- /dev/null +++ b/frontend/src/api/schema.d.ts @@ -0,0 +1,1286 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Status */ + get: operations["get_status_status_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Config + * @description Return all configuration sections. Secret field values are masked (empty string). + */ + get: operations["get_config_api_config_get"]; + /** + * Put Config + * @description Save configuration updates. + * + * - Blank secret value keeps the existing stored value (no change). + * - Invalid values return 422 and nothing is written to the database. + */ + put: operations["put_config_api_config_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/config/smtp/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Smtp Test + * @description Send a test SMTP email using the current runtime settings. + * + * Returns a structured result indicating success or the category of failure. + * Three possible outcomes: + * - 200 { "result": "success", "message": ... } + * - 400 { "result": "config-error", "message": ... } (EmailConfigurationError) + * - 502 { "result": "failed", "message": ... } (EmailDeliveryError) + * + * SMTP credentials are never echoed in the response. + */ + post: operations["post_smtp_test_api_config_smtp_test_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/locations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Locations + * @description Return location records with optional time-window filtering and pagination. + * + * - ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds. + * - Results are ordered by ``datetime`` ascending. + * - ``limit`` is capped at 5000 to prevent full-table exports. + */ + get: operations["get_locations_api_locations_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/poo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Poo + * @description Return poo records ordered by timestamp descending (most recent first). + * + * ``limit`` is capped at 1000 to prevent full-table exports. + */ + get: operations["get_poo_api_poo_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/public-ip": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Public Ip + * @description Return the current public IP state and recent history. + * + * - ``state`` is ``null`` if no IP check has been performed yet. + * - ``history`` is ordered by ``observed_at`` descending (most recent first). + * - ``limit`` applies to the history list and is capped at 1000. + */ + get: operations["get_public_ip_api_public_ip_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/locations/{person}/{datetime}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Location Record + * @description Delete the single location record identified by its composite PK. + * + * - Exactly one row is deleted; **404** if the PK does not exist. + * - No batch delete / truncate path is available. + */ + delete: operations["delete_location_record_api_locations__person___datetime__delete"]; + options?: never; + head?: never; + /** + * Patch Location + * @description Update the non-PK fields of a single location record. + * + * - ``person`` and ``datetime`` identify the row (composite PK) and are immutable. + * - Only ``latitude``, ``longitude``, and ``altitude`` may be updated. + * - Omitted body fields are left unchanged. + * - Returns **404** if the PK does not exist. + */ + patch: operations["patch_location_api_locations__person___datetime__patch"]; + trace?: never; + }; + "/api/poo/{timestamp}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Poo + * @description Delete the single poo record identified by its PK. + * + * - Exactly one row is deleted; **404** if the PK does not exist. + * - No batch delete / truncate path is available. + */ + delete: operations["delete_poo_api_poo__timestamp__delete"]; + options?: never; + head?: never; + /** + * Patch Poo + * @description Update the non-PK fields of a single poo record. + * + * - ``timestamp`` is the PK and is immutable. + * - Only ``status``, ``latitude``, and ``longitude`` may be updated. + * - Omitted body fields are left unchanged. + * - Returns **404** if the PK does not exist. + */ + patch: operations["patch_poo_api_poo__timestamp__patch"]; + trace?: never; + }; + "/api/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Session + * @description Return the current session user and CSRF token. Returns 401 if not authenticated. + */ + get: operations["get_session_api_session_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Login + * @description Authenticate with username and password. + * + * On success, sets an HttpOnly session cookie and returns the session user + CSRF token. + * On failure, returns 401 with no cookie set. + * No X-CSRF-Token required (unauthenticated endpoint). + */ + post: operations["post_login_api_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Logout + * @description Revoke the current session and clear the session cookie. + * Requires authentication and X-CSRF-Token header. + * Returns 204 No Content. + */ + post: operations["post_logout_api_auth_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Post Change Password + * @description Change the current user's password. + * Requires authentication and X-CSRF-Token header. + * On AuthPasswordChangeError returns 400 with a generic message. + * On success, force_password_change becomes False (handled by the service). + * Returns 204 No Content. + */ + post: operations["post_change_password_api_auth_password_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/homeassistant/publish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Publish From Homeassistant */ + post: operations["publish_from_homeassistant_homeassistant_publish_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/location/record": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Location Record */ + post: operations["create_location_record_location_record_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/poo/record": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create Poo Record */ + post: operations["create_poo_record_poo_record_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/poo/latest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Notify Latest Poo */ + get: operations["notify_latest_poo_poo_latest_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/public-ip/check": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Run Public Ip Check */ + get: operations["run_public_ip_check_public_ip_check_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ticktick/auth/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Start Ticktick Auth */ + get: operations["start_ticktick_auth_ticktick_auth_start_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ticktick/auth/code": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Handle Ticktick Auth Code */ + get: operations["handle_ticktick_auth_code_ticktick_auth_code_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** ConfigField */ + ConfigField: { + /** Env Name */ + env_name: string; + /** Label */ + label: string; + /** Value */ + value: string; + /** Secret */ + secret: boolean; + /** Input Type */ + input_type: string; + /** Configured */ + configured: boolean; + }; + /** ConfigResponse */ + ConfigResponse: { + /** Sections */ + sections: components["schemas"]["ConfigSection"][]; + }; + /** ConfigSection */ + ConfigSection: { + /** Name */ + name: string; + /** Fields */ + fields: components["schemas"]["ConfigField"][]; + }; + /** + * ConfigUpdateRequest + * @description Flat mapping of env_name → value, mirroring the existing form semantics. + */ + ConfigUpdateRequest: { + /** Updates */ + updates: { + [key: string]: string; + }; + }; + /** ConfigUpdateResponse */ + ConfigUpdateResponse: { + /** Sections */ + sections: components["schemas"]["ConfigSection"][]; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** LocationRecord */ + LocationRecord: { + /** Person */ + person: string; + /** Datetime */ + datetime: string; + /** Latitude */ + latitude: number; + /** Longitude */ + longitude: number; + /** Altitude */ + altitude: number | null; + }; + /** + * LocationUpdateRequest + * @description PATCH body for a location record — all fields optional; PK fields excluded. + */ + LocationUpdateRequest: { + /** Latitude */ + latitude?: number | null; + /** Longitude */ + longitude?: number | null; + /** Altitude */ + altitude?: number | null; + }; + /** LocationsResponse */ + LocationsResponse: { + /** Items */ + items: components["schemas"]["LocationRecord"][]; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + }; + /** LoginRequest */ + LoginRequest: { + /** Username */ + username: string; + /** Password */ + password: string; + }; + /** PasswordChangeRequest */ + PasswordChangeRequest: { + /** Current Password */ + current_password: string; + /** New Password */ + new_password: string; + /** Confirm Password */ + confirm_password: string; + }; + /** PooRecord */ + PooRecord: { + /** Timestamp */ + timestamp: string; + /** Status */ + status: string; + /** Latitude */ + latitude: number; + /** Longitude */ + longitude: number; + }; + /** PooResponse */ + PooResponse: { + /** Items */ + items: components["schemas"]["PooRecord"][]; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + }; + /** + * PooUpdateRequest + * @description PATCH body for a poo record — all fields optional; PK field excluded. + */ + PooUpdateRequest: { + /** Status */ + status?: string | null; + /** Latitude */ + latitude?: number | null; + /** Longitude */ + longitude?: number | null; + }; + /** PublicIPCheckResponse */ + PublicIPCheckResponse: { + /** + * Status + * @enum {string} + */ + status: "first_seen" | "unchanged" | "changed" | "error"; + /** + * Checked At + * Format: date-time + */ + checked_at: string; + /** Changed */ + changed: boolean; + }; + /** PublicIPHistorySchema */ + PublicIPHistorySchema: { + /** Id */ + id: number; + /** Ipv4 */ + ipv4: string; + /** + * Observed At + * Format: date-time + */ + observed_at: string; + /** Change Type */ + change_type: string; + /** Provider */ + provider: string | null; + }; + /** PublicIPResponse */ + PublicIPResponse: { + state: components["schemas"]["PublicIPStateSchema"] | null; + /** History */ + history: components["schemas"]["PublicIPHistorySchema"][]; + }; + /** PublicIPStateSchema */ + PublicIPStateSchema: { + /** Id */ + id: number; + /** Current Ipv4 */ + current_ipv4: string; + /** Previous Ipv4 */ + previous_ipv4: string | null; + /** + * First Seen At + * Format: date-time + */ + first_seen_at: string; + /** + * Last Checked At + * Format: date-time + */ + last_checked_at: string; + /** Last Changed At */ + last_changed_at: string | null; + /** Last Check Status */ + last_check_status: string; + /** Last Check Error */ + last_check_error: string | null; + /** Last Provider */ + last_provider: string | null; + }; + /** SessionResponse */ + SessionResponse: { + user: components["schemas"]["SessionUser"]; + /** Csrf Token */ + csrf_token: string; + }; + /** SessionUser */ + SessionUser: { + /** Username */ + username: string; + /** Force Password Change */ + force_password_change: boolean; + }; + /** + * SmtpTestResponse + * @description Response from POST /api/config/smtp/test. + */ + SmtpTestResponse: { + /** + * Result + * @enum {string} + */ + result: "success" | "config-error" | "failed"; + /** Message */ + message: string; + }; + /** StatusResponse */ + StatusResponse: { + /** Status */ + status: string; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + get_status_status_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusResponse"]; + }; + }; + }; + }; + get_config_api_config_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigResponse"]; + }; + }; + }; + }; + put_config_api_config_put: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfigUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfigUpdateResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + post_smtp_test_api_config_smtp_test_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmtpTestResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmtpTestResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Bad Gateway */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SmtpTestResponse"]; + }; + }; + }; + }; + get_locations_api_locations_get: { + parameters: { + query?: { + limit?: number; + offset?: number; + start?: string | null; + end?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LocationsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_poo_api_poo_get: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PooResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_public_ip_api_public_ip_get: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicIPResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_location_record_api_locations__person___datetime__delete: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + person: string; + datetime: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_location_api_locations__person___datetime__patch: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + person: string; + datetime: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["LocationUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LocationRecord"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_poo_api_poo__timestamp__delete: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + timestamp: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + patch_poo_api_poo__timestamp__patch: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path: { + timestamp: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PooUpdateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PooRecord"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_session_api_session_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + }; + }; + post_login_api_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LoginRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + post_logout_api_auth_logout_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + post_change_password_api_auth_password_post: { + parameters: { + query?: never; + header?: { + "X-CSRF-Token"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordChangeRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + publish_from_homeassistant_homeassistant_publish_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + create_location_record_location_record_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + create_poo_record_poo_record_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + notify_latest_poo_poo_latest_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + run_public_ip_check_public_ip_check_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicIPCheckResponse"]; + }; + }; + }; + }; + start_ticktick_auth_ticktick_auth_start_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + handle_ticktick_auth_code_ticktick_auth_code_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; +} diff --git a/frontend/src/auth/ProtectedRoute.tsx b/frontend/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..21bf596 --- /dev/null +++ b/frontend/src/auth/ProtectedRoute.tsx @@ -0,0 +1,46 @@ +/** + * ProtectedRoute — renders children when authenticated; redirects to /login otherwise. + * + * Additional gate (M2-T07): + * - If the authenticated user has force_password_change === true, redirect to + * /change-password instead of rendering children. This prevents access to any + * protected page until the password is changed. + * - Shows a loading spinner while the session is still resolving to avoid flash-of-login. + * - On unauthenticated access, preserves the intended destination in location.state.from + * so LoginPage can redirect back after login. + */ + +import type { ReactNode } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { Center, Loader } from '@mantine/core' +import { useSession } from './SessionProvider' + +interface ProtectedRouteProps { + children: ReactNode +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { status, user } = useSession() + const location = useLocation() + + if (status === 'loading') { + // Render a centred spinner while we check the session — avoids a flash to /login. + return ( +
+ +
+ ) + } + + if (status === 'unauthenticated') { + // Preserve the intended destination so LoginPage can redirect back after login. + return + } + + // Authenticated but forced to change password — gate all protected pages. + if (user?.force_password_change && location.pathname !== '/change-password') { + return + } + + return <>{children} +} diff --git a/frontend/src/auth/SessionProvider.tsx b/frontend/src/auth/SessionProvider.tsx new file mode 100644 index 0000000..58eae8b --- /dev/null +++ b/frontend/src/auth/SessionProvider.tsx @@ -0,0 +1,109 @@ +/** + * SessionProvider — fetches GET /api/session once on mount via TanStack Query. + * + * Contract (orchestrator-decisions.md §4, §11): + * - 200 → authenticated; calls setCsrfToken(data.csrf_token) so write requests work. + * - 401 → unauthenticated (not an error toast; normal state before login). + * - Exposes { user, status } to descendants via useSession(). + * + * Also registers the 401 → /login redirect with the API client middleware. + */ + +import { createContext, useContext, useEffect, type ReactNode } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import apiClient, { registerLoginRedirect } from '../api/client' +import { setCsrfToken } from '../api/csrf' +import type { components } from '../api/schema.d.ts' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type SessionUser = components['schemas']['SessionUser'] + +type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated' + +interface SessionContextValue { + user: SessionUser | null + status: SessionStatus +} + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const SessionContext = createContext({ + user: null, + status: 'loading', +}) + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +/** Access the current session from any descendant component. */ +export function useSession(): SessionContextValue { + return useContext(SessionContext) +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +interface SessionProviderProps { + children: ReactNode +} + +export function SessionProvider({ children }: SessionProviderProps) { + const navigate = useNavigate() + const queryClient = useQueryClient() + + // Register the 401 redirect callback with the API client once. + useEffect(() => { + registerLoginRedirect(() => { + // Invalidate the session query so any subscriber re-fetches (→ unauthenticated). + queryClient.invalidateQueries({ queryKey: ['session'] }) + navigate('/login', { replace: true }) + }) + }, [navigate, queryClient]) + + const { data, status, error } = useQuery({ + queryKey: ['session'], + queryFn: async () => { + const res = await apiClient.GET('/api/session') + // openapi-fetch returns { data, error, response }. + // On 401 the middleware already navigates; here data will be undefined. + return res.data ?? null + }, + // Don't treat 401 as a React Query "error" — it's a normal unauthenticated state. + retry: false, + staleTime: 1000 * 60 * 5, // 5 minutes + }) + + // When we get session data, store the CSRF token. + useEffect(() => { + if (data?.csrf_token) { + setCsrfToken(data.csrf_token) + } + }, [data]) + + let sessionStatus: SessionStatus + if (status === 'pending') { + sessionStatus = 'loading' + } else if (status === 'error' || data === null || !data) { + // 401 returns null from our queryFn; any actual network error → unauthenticated. + sessionStatus = 'unauthenticated' + // Suppress unused variable warning for error in non-401 cases + void error + } else { + sessionStatus = 'authenticated' + } + + const value: SessionContextValue = { + user: data?.user ?? null, + status: sessionStatus, + } + + return {children} +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c70efbc --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +/** + * Entry point — mounts the React app into #root. + */ + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' + +const rootElement = document.getElementById('root') +if (!rootElement) { + throw new Error('Root element #root not found in document') +} + +createRoot(rootElement).render( + + + , +) diff --git a/frontend/src/map/RecordsMap.heat.test.tsx b/frontend/src/map/RecordsMap.heat.test.tsx new file mode 100644 index 0000000..b6d6755 --- /dev/null +++ b/frontend/src/map/RecordsMap.heat.test.tsx @@ -0,0 +1,118 @@ +/** + * HeatLayers regression test — post-walkthrough fix. + * + * Bug: the heat layer's `setLatLngs` was called BEFORE the layer was added to the + * map. A leaflet.heat layer that is not on a map has a null `_map`, and + * `setLatLngs -> redraw` dereferences `_map._animating`, throwing + * "Cannot read properties of null (reading '_animating')" and white-screening + * the whole SPA right after login. + * + * This test exercises the REAL HeatLayers code path (not a wholesale RecordsMap + * mock) and asserts the layer is added to the map BEFORE setLatLngs is called. + * Against the old code (setLatLngs first), the ordering assertion fails. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render } from '@testing-library/react' + +const { callLog, setLatLngsSpy, mapAddLayerSpy } = vi.hoisted(() => { + const callLog: string[] = [] + const setLatLngsSpy = vi.fn((_pts: unknown) => { + callLog.push('setLatLngs') + }) + const mapAddLayerSpy = vi.fn((_layer: unknown) => { + callLog.push('addLayer') + }) + return { callLog, setLatLngsSpy, mapAddLayerSpy } +}) + +// Mock leaflet. heatLayer returns a fake layer whose setLatLngs logs call order; +// Icon/DivIcon/marker exist because RecordsMap.tsx runs icon setup at module load. +vi.mock('leaflet', () => { + class FakeIcon { + constructor(_opts: unknown) {} + static Default = { prototype: {}, mergeOptions: vi.fn() } + } + return { + Icon: FakeIcon, + DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) { + return {} + }), + heatLayer: vi.fn(() => ({ setLatLngs: setLatLngsSpy, setOptions: vi.fn(), addTo: vi.fn() })), + markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), clearLayers: vi.fn() })), + marker: vi.fn(() => ({ bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() })), + default: {}, + } +}) + +vi.mock('leaflet.heat', () => ({})) +vi.mock('leaflet.markercluster', () => ({})) +vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' })) +vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' })) +vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' })) +vi.mock('leaflet/dist/leaflet.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({})) + +// useMap returns a fake map; hasLayer=false so addLayer is exercised. +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + TileLayer: () => null, + useMap: () => ({ + addLayer: mapAddLayerSpy, + removeLayer: vi.fn(), + hasLayer: () => false, + getSize: () => ({ x: 800, y: 600 }), + latLngToContainerPoint: () => ({ x: 100, y: 100 }), + on: vi.fn(), + off: vi.fn(), + }), +})) + +import { HeatLayers } from './RecordsMap' +import type { HeatPoint } from './mapUtils' + +const heatPoints: HeatPoint[] = [ + [39.9, 116.4, 1], + [39.91, 116.41, 1], +] + +describe('HeatLayers (real code path — regression for null _map crash)', () => { + beforeEach(() => { + vi.clearAllMocks() + callLog.length = 0 + }) + + it('adds the heat layer to the map BEFORE calling setLatLngs', () => { + render( + , + ) + + // Data was applied... + expect(setLatLngsSpy).toHaveBeenCalledWith(heatPoints) + // ...and the layer was added to the map first. The old buggy order + // (setLatLngs before addLayer) makes this fail. + expect(callLog).toEqual(['addLayer', 'setLatLngs']) + expect(callLog.indexOf('addLayer')).toBeLessThan(callLog.indexOf('setLatLngs')) + }) + + it('does not call setLatLngs while the layer is hidden (off the map)', () => { + render( + , + ) + + // Hidden layers are never on the map, so setLatLngs must not run on them. + expect(setLatLngsSpy).not.toHaveBeenCalled() + expect(mapAddLayerSpy).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/map/RecordsMap.scatter.test.tsx b/frontend/src/map/RecordsMap.scatter.test.tsx new file mode 100644 index 0000000..cf6c066 --- /dev/null +++ b/frontend/src/map/RecordsMap.scatter.test.tsx @@ -0,0 +1,246 @@ +/** + * ScatterLayer unit test — M2-T09 REWORK 1. + * + * This test exercises the REAL ScatterLayer code path (not a wholesale RecordsMap mock). + * It verifies that ScatterLayer uses the imported leaflet namespace (L.markerClusterGroup) + * rather than window.L / globalThis.L, which would silently fail in Vite ESM bundles. + * + * The test: + * - mocks react-leaflet's useMap() to return a fake map object + * - provides a mock markerClusterGroup spy via the leaflet module mock + * - renders ScatterLayer with some points + * - asserts that L.markerClusterGroup was called (i.e. the import path is used) + * - asserts that addLayer was called for each point + * - asserts that clicking a marker invokes onSelectLocation / onSelectPoo + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import type { ReactNode } from 'react' + +// --------------------------------------------------------------------------- +// Use vi.hoisted() to define mocks that are referenced inside vi.mock factories. +// vi.mock() factories are hoisted to the top of the file, so any variables they +// reference must also be hoisted. +// --------------------------------------------------------------------------- + +const { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers } = + vi.hoisted(() => { + const clickHandlers: Array<() => void> = [] + const fakeAddLayer = vi.fn() + const fakeCluster = { + addLayer: fakeAddLayer, + addTo: vi.fn(), + clearLayers: vi.fn(), + } + const markerClusterGroupSpy = vi.fn(() => fakeCluster) + const fakeMapAddLayer = vi.fn() + return { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers: clickHandlers } + }) + +// --------------------------------------------------------------------------- +// Mock leaflet BEFORE importing ScatterLayer. +// We use the hoisted spy so vi.mock factory can reference it safely. +// --------------------------------------------------------------------------- + +vi.mock('leaflet', () => { + const markerClusterGroupSpy_ = markerClusterGroupSpy + const markerClickHandlers_ = markerClickHandlers + + // Icon must be a real constructor (used as `new Icon(...)`) + class FakeIcon { + constructor(_opts: unknown) {} + static Default = { prototype: {}, mergeOptions: vi.fn() } + } + + return { + Icon: FakeIcon, + DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) { return {} }), + heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })), + markerClusterGroup: markerClusterGroupSpy_, + marker: vi.fn((_latlng: unknown, _opts: unknown) => { + return { + bindTooltip: vi.fn().mockReturnThis(), + on: vi.fn((event: string, handler: () => void) => { + if (event === 'click') { + markerClickHandlers_.push(handler) + } + return { bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() } + }), + } + }), + // `import * as L from 'leaflet'` in RecordsMap.tsx resolves to this module. + // Vitest's module mock exposes all named exports as the namespace object, + // so markerClusterGroup at the top level IS accessible as L.markerClusterGroup. + default: { + markerClusterGroup: markerClusterGroupSpy_, + }, + } +}) + +vi.mock('leaflet.heat', () => ({})) +vi.mock('leaflet.markercluster', () => ({})) + +// Mock image imports +vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' })) +vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' })) +vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' })) + +// Mock CSS imports +vi.mock('leaflet/dist/leaflet.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({})) + +// --------------------------------------------------------------------------- +// Mock react-leaflet: MapContainer renders children, useMap returns fake map. +// --------------------------------------------------------------------------- + +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + TileLayer: () => null, + useMap: () => ({ + addLayer: fakeMapAddLayer, + removeLayer: vi.fn(), + hasLayer: vi.fn(() => false), + }), +})) + +// --------------------------------------------------------------------------- +// Import ScatterLayer AFTER mocks are set up. +// --------------------------------------------------------------------------- + +import { ScatterLayer } from './RecordsMap' +import type { LocationMapPoint, PooMapPoint } from './mapUtils' +import type { LocationRecord, PooRecord } from '../records' + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +const locationRecord: LocationRecord = { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: null, +} +const locationPoints: LocationMapPoint[] = [ + { lat: 39.9, lng: 116.4, record: locationRecord }, +] + +const pooRecord: PooRecord = { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, +} +const pooPoints: PooMapPoint[] = [ + { lat: 39.91, lng: 116.41, record: pooRecord }, +] + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ScatterLayer (real code path — not mocked RecordsMap)', () => { + beforeEach(() => { + vi.clearAllMocks() + markerClickHandlers.length = 0 + }) + + it('calls L.markerClusterGroup (imported namespace) when showScatter=true', () => { + render( + , + ) + + // KEY assertion: markerClusterGroup was called via the IMPORTED namespace. + // With the old window.L / globalThis.L approach, this spy would never be + // invoked because window.L is undefined in Vite ESM bundles. + expect(markerClusterGroupSpy).toHaveBeenCalledOnce() + expect(markerClusterGroupSpy).toHaveBeenCalledWith({ + maxClusterRadius: 50, + showCoverageOnHover: false, + }) + }) + + it('calls cluster group addLayer for each location and poo scatter point', () => { + render( + , + ) + + // One addLayer call per point (1 location + 1 poo = 2). + expect(fakeAddLayer).toHaveBeenCalledTimes(2) + // The cluster group itself must be added to the map. + const fakeCluster = markerClusterGroupSpy.mock.results[0]?.value + expect(fakeMapAddLayer).toHaveBeenCalledWith(fakeCluster) + }) + + it('does NOT create cluster group when showScatter=false', () => { + render( + , + ) + + expect(markerClusterGroupSpy).not.toHaveBeenCalled() + expect(fakeAddLayer).not.toHaveBeenCalled() + }) + + it('invokes onSelectLocation when a location marker is clicked', () => { + const onSelectLocation = vi.fn() + + render( + , + ) + + // At least one marker click handler should have been registered. + expect(markerClickHandlers.length).toBeGreaterThan(0) + // Simulate click on the first (location) marker. + markerClickHandlers[0]() + expect(onSelectLocation).toHaveBeenCalledOnce() + expect(onSelectLocation).toHaveBeenCalledWith(locationRecord) + }) + + it('invokes onSelectPoo when a poo marker is clicked', () => { + const onSelectPoo = vi.fn() + + render( + , + ) + + expect(markerClickHandlers.length).toBeGreaterThan(0) + markerClickHandlers[0]() + expect(onSelectPoo).toHaveBeenCalledOnce() + expect(onSelectPoo).toHaveBeenCalledWith(pooRecord) + }) +}) diff --git a/frontend/src/map/RecordsMap.tsx b/frontend/src/map/RecordsMap.tsx new file mode 100644 index 0000000..26343f0 --- /dev/null +++ b/frontend/src/map/RecordsMap.tsx @@ -0,0 +1,345 @@ +/** + * RecordsMap — self-contained Leaflet map component (M2-T09). + * + * THIS IS THE ONLY MODULE IN THE APP THAT IMPORTS LEAFLET / REACT-LEAFLET. + * All data fetching and state lives outside; this component receives typed props. + */ + +import { useEffect, useRef, useCallback } from 'react' +import { MapContainer, TileLayer, useMap } from 'react-leaflet' +import * as L from 'leaflet' +import { + Icon, + DivIcon, + marker as leafletMarker, + heatLayer as leafletHeatLayer, + type HeatLayer, +} from 'leaflet' + +// Leaflet CSS — must be imported once; this component is the single place. +import 'leaflet/dist/leaflet.css' +import 'leaflet.markercluster/dist/MarkerCluster.css' +import 'leaflet.markercluster/dist/MarkerCluster.Default.css' + +// Side-effect imports (augment L with heatLayer and markerClusterGroup) +import 'leaflet.heat' +import 'leaflet.markercluster' + +import { peakGridCount } from './mapUtils' +import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils' +import type { LocationRecord, PooRecord } from '../records' + +// Fix default Leaflet marker icon paths broken by Vite asset handling. +import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png' +import markerIcon from 'leaflet/dist/images/marker-icon.png' +import markerShadow from 'leaflet/dist/images/marker-shadow.png' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +delete (Icon.Default.prototype as any)._getIconUrl +Icon.Default.mergeOptions({ + iconRetinaUrl: markerIcon2x, + iconUrl: markerIcon, + shadowUrl: markerShadow, +}) + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface RecordsMapProps { + locationHeatPoints: HeatPoint[] + pooHeatPoints: HeatPoint[] + locationScatterPoints: LocationMapPoint[] + pooScatterPoints: PooMapPoint[] + + showLocationHeat: boolean + showPooHeat: boolean + showScatter: boolean + + onSelectLocation?: (record: LocationRecord) => void + onSelectPoo?: (record: PooRecord) => void + + /** Map container height (CSS value). Default: '100%'. */ + height?: string + + /** Use dark base tiles to match the app's dark color scheme. */ + dark?: boolean +} + +// OSM (light) and CARTO dark_all (dark) raster tiles — both zero-key. +const LIGHT_TILES = { + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: + '© OpenStreetMap contributors', +} +const DARK_TILES = { + url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + attribution: + '© OpenStreetMap contributors © CARTO', +} + +// --------------------------------------------------------------------------- +// Inner child: Heat layers (uses useMap hook — must be inside MapContainer) +// --------------------------------------------------------------------------- + +interface HeatLayerChildProps { + locationHeatPoints: HeatPoint[] + pooHeatPoints: HeatPoint[] + showLocationHeat: boolean + showPooHeat: boolean +} + +// Heat layer geometry. maxZoom:0 makes leaflet.heat's zoom intensity factor f=1 +// at every zoom, so accumulated per-cell intensity equals the raw point count — +// which lets us normalize with a pixel-grid count below. +const LOC_HEAT = { radius: 20, blur: 15 } +const POO_HEAT = { radius: 25, blur: 18 } + +/** + * leaflet.heat `max` (normalization denominator) for the CURRENT viewport: + * project the points that are visible (within the map size + a radius margin) to + * container pixels, then count the densest pixel cell using leaflet.heat's own + * grid (cell = (radius+blur)/2). The densest visible cluster maps to the hot + * color; recomputing on every zoom/pan keeps it normalized to what's on screen. + */ +function viewportHeatMax(map: L.Map, points: HeatPoint[], radius: number, blur: number): number { + if (points.length === 0) return 1 + const cell = (radius + blur) / 2 + const size = map.getSize() + const margin = radius + blur + const coords: Array<[number, number]> = [] + for (let i = 0; i < points.length; i++) { + const p = map.latLngToContainerPoint([points[i][0], points[i][1]]) + if (p.x < -margin || p.y < -margin || p.x > size.x + margin || p.y > size.y + margin) continue + coords.push([p.x, p.y]) + } + return peakGridCount(coords, cell) +} + +export function HeatLayers({ + locationHeatPoints, + pooHeatPoints, + showLocationHeat, + showPooHeat, +}: HeatLayerChildProps) { + const map = useMap() + const locationLayerRef = useRef(null) + const pooLayerRef = useRef(null) + + // Latest data/visibility in refs so the once-registered map move/zoom handler + // re-normalizes against the current points without re-subscribing. + const locPointsRef = useRef(locationHeatPoints) + const pooPointsRef = useRef(pooHeatPoints) + const showLocRef = useRef(showLocationHeat) + const showPooRef = useRef(showPooHeat) + useEffect(() => { + locPointsRef.current = locationHeatPoints + pooPointsRef.current = pooHeatPoints + showLocRef.current = showLocationHeat + showPooRef.current = showPooHeat + }) + + // Location heat layer + useEffect(() => { + if (!locationLayerRef.current) { + locationLayerRef.current = leafletHeatLayer([], { + ...LOC_HEAT, + maxZoom: 0, + gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' }, + }) + } + const layer = locationLayerRef.current + if (showLocationHeat) { + // Add the layer to the map BEFORE setLatLngs. A heat layer that is not on + // a map has a null `_map`, and `setLatLngs -> redraw` dereferences + // `_map._animating`, which throws and white-screens the SPA. + if (!map.hasLayer(layer)) map.addLayer(layer) + layer.setLatLngs(locationHeatPoints) + layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) }) + } else { + if (map.hasLayer(layer)) map.removeLayer(layer) + } + return () => { + if (map.hasLayer(layer)) map.removeLayer(layer) + } + }, [map, locationHeatPoints, showLocationHeat]) + + // Poo heat layer + useEffect(() => { + if (!pooLayerRef.current) { + pooLayerRef.current = leafletHeatLayer([], { + ...POO_HEAT, + maxZoom: 0, + // High-frequency poo spots reach red (per request); mid tones stay + // yellow/orange to distinguish from the location layer. + gradient: { 0.4: 'yellow', 0.7: 'orange', 1: 'red' }, + }) + } + const layer = pooLayerRef.current + if (showPooHeat) { + // Add to the map before setLatLngs (see the location heat layer above). + if (!map.hasLayer(layer)) map.addLayer(layer) + layer.setLatLngs(pooHeatPoints) + layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) }) + } else { + if (map.hasLayer(layer)) map.removeLayer(layer) + } + return () => { + if (map.hasLayer(layer)) map.removeLayer(layer) + } + }, [map, pooHeatPoints, showPooHeat]) + + // Re-normalize each visible layer to the viewport peak on pan/zoom. + useEffect(() => { + const recompute = () => { + const loc = locationLayerRef.current + if (loc && showLocRef.current && map.hasLayer(loc)) { + loc.setOptions({ max: viewportHeatMax(map, locPointsRef.current, LOC_HEAT.radius, LOC_HEAT.blur) }) + } + const poo = pooLayerRef.current + if (poo && showPooRef.current && map.hasLayer(poo)) { + poo.setOptions({ max: viewportHeatMax(map, pooPointsRef.current, POO_HEAT.radius, POO_HEAT.blur) }) + } + } + map.on('moveend', recompute) + map.on('zoomend', recompute) + return () => { + map.off('moveend', recompute) + map.off('zoomend', recompute) + } + }, [map]) + + return null +} + +// --------------------------------------------------------------------------- +// Inner child: Scatter / cluster layer +// --------------------------------------------------------------------------- + +interface ScatterLayerChildProps { + locationScatterPoints: LocationMapPoint[] + pooScatterPoints: PooMapPoint[] + showScatter: boolean + onSelectLocation?: (record: LocationRecord) => void + onSelectPoo?: (record: PooRecord) => void +} + +const locationIcon = new Icon({ + iconUrl: markerIcon, + iconRetinaUrl: markerIcon2x, + shadowUrl: markerShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}) + +const pooIcon = new DivIcon({ + html: '
💩
', + className: '', + iconSize: [24, 24], + iconAnchor: [12, 12], +}) + +export function ScatterLayer({ + locationScatterPoints, + pooScatterPoints, + showScatter, + onSelectLocation, + onSelectPoo, +}: ScatterLayerChildProps) { + const map = useMap() + const clusterGroupRef = useRef(null) + + const rebuild = useCallback(() => { + if (clusterGroupRef.current) { + map.removeLayer(clusterGroupRef.current) + clusterGroupRef.current = null + } + if (!showScatter) return + + // markerClusterGroup is augmented onto the imported L namespace by the + // leaflet.markercluster side-effect import above. Using the imported + // namespace (not window.L) is what works in Vite ESM bundles. + const group = L.markerClusterGroup({ maxClusterRadius: 50, showCoverageOnHover: false }) + + for (const pt of locationScatterPoints) { + const m = leafletMarker([pt.lat, pt.lng], { icon: locationIcon }) + m.bindTooltip(`${pt.record.person}
${pt.record.datetime}`, { sticky: true }) + if (onSelectLocation) m.on('click', () => onSelectLocation(pt.record)) + group.addLayer(m) + } + + for (const pt of pooScatterPoints) { + const m = leafletMarker([pt.lat, pt.lng], { icon: pooIcon }) + m.bindTooltip(`${pt.record.timestamp}
${pt.record.status}`, { sticky: true }) + if (onSelectPoo) m.on('click', () => onSelectPoo(pt.record)) + group.addLayer(m) + } + + map.addLayer(group) + clusterGroupRef.current = group + }, [map, locationScatterPoints, pooScatterPoints, showScatter, onSelectLocation, onSelectPoo]) + + useEffect(() => { + rebuild() + return () => { + if (clusterGroupRef.current) { + map.removeLayer(clusterGroupRef.current) + clusterGroupRef.current = null + } + } + }, [rebuild, map]) + + return null +} + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- + +/** Default map center: Beijing area. */ +const DEFAULT_CENTER: [number, number] = [39.9, 116.4] +const DEFAULT_ZOOM = 11 + +export function RecordsMap({ + locationHeatPoints, + pooHeatPoints, + locationScatterPoints, + pooScatterPoints, + showLocationHeat, + showPooHeat, + showScatter, + onSelectLocation, + onSelectPoo, + height = '100%', + dark = false, +}: RecordsMapProps) { + const tiles = dark ? DARK_TILES : LIGHT_TILES + return ( + + {/* key forces a clean tile-layer swap when the color scheme changes */} + + + + + + + ) +} diff --git a/frontend/src/map/gridCount.test.ts b/frontend/src/map/gridCount.test.ts new file mode 100644 index 0000000..39b50cd --- /dev/null +++ b/frontend/src/map/gridCount.test.ts @@ -0,0 +1,42 @@ +/** + * Tests for peakGridCount — the pure pixel-grid peak counter used to normalize + * each heat layer to the densest cell visible in the current viewport. + */ + +import { describe, it, expect } from 'vitest' +import { peakGridCount } from './mapUtils' + +describe('peakGridCount', () => { + it('returns 1 for empty input (no divide-by-zero)', () => { + expect(peakGridCount([], 10)).toBe(1) + }) + + it('counts coords sharing a grid cell and returns the peak', () => { + const coords: Array<[number, number]> = [ + [0, 0], + [3, 4], // same 10px cell as [0,0] + [9, 9], // same 10px cell + [100, 100], // different cell + ] + expect(peakGridCount(coords, 10)).toBe(3) + }) + + it('separates coords into different cells by cellSize', () => { + const coords: Array<[number, number]> = [ + [0, 0], + [10, 0], // next cell over at cellSize 10 + [20, 0], // next again + ] + expect(peakGridCount(coords, 10)).toBe(1) + }) + + it('a denser cluster yields a larger peak (drives per-layer normalization)', () => { + const dense: Array<[number, number]> = Array.from({ length: 12 }, () => [5, 5] as [number, number]) + const sparse: Array<[number, number]> = [ + [5, 5], + [5, 5], + ] + expect(peakGridCount(dense, 10)).toBe(12) + expect(peakGridCount(sparse, 10)).toBe(2) + }) +}) diff --git a/frontend/src/map/index.ts b/frontend/src/map/index.ts new file mode 100644 index 0000000..1b01953 --- /dev/null +++ b/frontend/src/map/index.ts @@ -0,0 +1,21 @@ +/** + * Public surface of the map module (M2-T09). + * Only RecordsMap.tsx imports leaflet — external code should not. + */ +export { RecordsMap } from './RecordsMap' +export type { RecordsMapProps } from './RecordsMap' + +export { + locationsToHeatPoints, + pooToHeatPoints, + locationsToMapPoints, + pooToMapPoints, + filterPooByTimeWindow, + daysAgoISO, + nowISO, + computeCenter, + TIME_PRESETS, + presetRange, + shiftRange, +} from './mapUtils' +export type { HeatPoint, LocationMapPoint, PooMapPoint, TimePreset } from './mapUtils' diff --git a/frontend/src/map/leaflet-heat.d.ts b/frontend/src/map/leaflet-heat.d.ts new file mode 100644 index 0000000..a3cc85f --- /dev/null +++ b/frontend/src/map/leaflet-heat.d.ts @@ -0,0 +1,40 @@ +/** + * Ambient type declarations for leaflet.heat (no @types package available). + * + * This file must be a MODULE (has a top-level export) so that `declare module 'leaflet'` + * is treated as an AUGMENTATION of the existing leaflet types, not a replacement. + * Without the export, the `declare module 'leaflet'` block would shadow all of @types/leaflet. + */ + +// This empty export makes the file a module, enabling proper augmentation semantics. +export {} + +// Augment the 'leaflet' module to add heatLayer and HeatLayer types. +declare module 'leaflet' { + type HeatLatLngTuple = [number, number] | [number, number, number] + + interface HeatLayerOptions { + minOpacity?: number + maxZoom?: number + max?: number + radius?: number + blur?: number + gradient?: Record + } + + class HeatLayer extends Layer { + setLatLngs(latlngs: HeatLatLngTuple[]): this + addLatLng(latlng: HeatLatLngTuple): this + setOptions(options: HeatLayerOptions): this + redraw(): this + } + + function heatLayer(latlngs: HeatLatLngTuple[], options?: HeatLayerOptions): HeatLayer +} + +// Declare leaflet.heat as a side-effect-only module. +declare module 'leaflet.heat' { + // Side-effect: augments the Leaflet global with the heatLayer plugin. + const _: undefined + export default _ +} diff --git a/frontend/src/map/mapUtils.test.ts b/frontend/src/map/mapUtils.test.ts new file mode 100644 index 0000000..36603e7 --- /dev/null +++ b/frontend/src/map/mapUtils.test.ts @@ -0,0 +1,196 @@ +/** + * Unit tests for mapUtils.ts — pure logic, no leaflet, runs in jsdom. + */ + +import { describe, it, expect } from 'vitest' +import { + locationsToHeatPoints, + pooToHeatPoints, + locationsToMapPoints, + pooToMapPoints, + filterPooByTimeWindow, + computeCenter, + daysAgoISO, +} from './mapUtils' +import type { LocationRecord, PooRecord } from '../records' + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const loc1: LocationRecord = { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: 50, +} +const loc2: LocationRecord = { + person: 'alice', + datetime: '2026-01-20T12:00:00Z', + latitude: 39.95, + longitude: 116.45, + altitude: null, +} + +const poo1: PooRecord = { + timestamp: '2026-01-10T08:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, +} +const poo2: PooRecord = { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.92, + longitude: 116.42, +} +const poo3: PooRecord = { + timestamp: '2026-02-01T09:00:00Z', + status: 'done', + latitude: 39.93, + longitude: 116.43, +} + +// --------------------------------------------------------------------------- +// locationsToHeatPoints +// --------------------------------------------------------------------------- + +describe('locationsToHeatPoints', () => { + it('converts records to [lat, lng, 1] tuples', () => { + const pts = locationsToHeatPoints([loc1, loc2]) + expect(pts).toHaveLength(2) + expect(pts[0]).toEqual([39.9, 116.4, 1]) + expect(pts[1]).toEqual([39.95, 116.45, 1]) + }) + + it('returns empty array for empty input', () => { + expect(locationsToHeatPoints([])).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// pooToHeatPoints +// --------------------------------------------------------------------------- + +describe('pooToHeatPoints', () => { + it('converts poo records to heat points', () => { + const pts = pooToHeatPoints([poo1]) + expect(pts).toHaveLength(1) + expect(pts[0]).toEqual([39.91, 116.41, 1]) + }) +}) + +// --------------------------------------------------------------------------- +// locationsToMapPoints +// --------------------------------------------------------------------------- + +describe('locationsToMapPoints', () => { + it('attaches original record to each point', () => { + const pts = locationsToMapPoints([loc1]) + expect(pts).toHaveLength(1) + expect(pts[0].lat).toBe(39.9) + expect(pts[0].lng).toBe(116.4) + expect(pts[0].record).toBe(loc1) + }) +}) + +// --------------------------------------------------------------------------- +// pooToMapPoints +// --------------------------------------------------------------------------- + +describe('pooToMapPoints', () => { + it('attaches original poo record to each point', () => { + const pts = pooToMapPoints([poo1]) + expect(pts[0].record).toBe(poo1) + }) +}) + +// --------------------------------------------------------------------------- +// filterPooByTimeWindow — client-side time filter +// --------------------------------------------------------------------------- + +describe('filterPooByTimeWindow', () => { + const records = [poo1, poo2, poo3] + // timestamps: 2026-01-10, 2026-01-20, 2026-02-01 + + it('returns all records when start and end are both null', () => { + expect(filterPooByTimeWindow(records, null, null)).toHaveLength(3) + }) + + it('filters by start (inclusive)', () => { + const result = filterPooByTimeWindow(records, '2026-01-15T00:00:00Z', null) + expect(result).toHaveLength(2) + expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z') + expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z') + }) + + it('filters by end (inclusive)', () => { + const result = filterPooByTimeWindow(records, null, '2026-01-20T09:00:00Z') + expect(result).toHaveLength(2) + expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z') + expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z') + }) + + it('filters by both start and end', () => { + const result = filterPooByTimeWindow( + records, + '2026-01-15T00:00:00Z', + '2026-01-25T00:00:00Z', + ) + expect(result).toHaveLength(1) + expect(result[0].timestamp).toBe('2026-01-20T09:00:00Z') + }) + + it('returns empty when no records match', () => { + const result = filterPooByTimeWindow(records, '2027-01-01T00:00:00Z', null) + expect(result).toHaveLength(0) + }) + + it('includes records exactly at start boundary', () => { + const result = filterPooByTimeWindow(records, '2026-01-10T08:00:00Z', null) + expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z') + }) + + it('includes records exactly at end boundary', () => { + const result = filterPooByTimeWindow(records, null, '2026-02-01T09:00:00Z') + expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z') + }) +}) + +// --------------------------------------------------------------------------- +// computeCenter +// --------------------------------------------------------------------------- + +describe('computeCenter', () => { + it('returns null for empty array', () => { + expect(computeCenter([])).toBeNull() + }) + + it('returns the point for a single-element array', () => { + const result = computeCenter([{ lat: 10, lng: 20 }]) + expect(result).toEqual([10, 20]) + }) + + it('returns the average of multiple points', () => { + const result = computeCenter([ + { lat: 0, lng: 0 }, + { lat: 4, lng: 6 }, + ]) + expect(result).toEqual([2, 3]) + }) +}) + +// --------------------------------------------------------------------------- +// daysAgoISO +// --------------------------------------------------------------------------- + +describe('daysAgoISO', () => { + it('returns a valid ISO string in the past', () => { + const result = daysAgoISO(7) + expect(typeof result).toBe('string') + const d = new Date(result) + expect(isNaN(d.getTime())).toBe(false) + expect(d.getTime()).toBeLessThan(Date.now()) + }) +}) diff --git a/frontend/src/map/mapUtils.ts b/frontend/src/map/mapUtils.ts new file mode 100644 index 0000000..5fe256d --- /dev/null +++ b/frontend/src/map/mapUtils.ts @@ -0,0 +1,184 @@ +/** + * Pure data-transform utilities for the map view (M2-T09). + * No leaflet imports — these functions are unit-testable in jsdom. + */ + +import type { LocationRecord, PooRecord } from '../records' + +/** A heat point for L.heatLayer: [lat, lng, intensity]. */ +export type HeatPoint = [number, number, number] + +/** Map point with attached source record for click-to-edit. */ +export interface LocationMapPoint { + lat: number + lng: number + record: LocationRecord +} + +export interface PooMapPoint { + lat: number + lng: number + record: PooRecord +} + +// --------------------------------------------------------------------------- +// Transforms +// --------------------------------------------------------------------------- + +/** + * Convert location records to heat points. + * All points get intensity=1; callers can adjust if needed. + */ +export function locationsToHeatPoints(records: LocationRecord[]): HeatPoint[] { + return records.map((r) => [r.latitude, r.longitude, 1]) +} + +/** + * Convert poo records to heat points. + */ +export function pooToHeatPoints(records: PooRecord[]): HeatPoint[] { + return records.map((r) => [r.latitude, r.longitude, 1]) +} + +/** + * Peak number of 2D coordinates that fall into the same `cellSize`-sized grid + * cell. Pure + leaflet-free so it is unit-testable. + * + * Used by the map heat normalization: project the VISIBLE points to screen + * pixels (in the map component), then this returns the densest pixel cell's + * count, which becomes leaflet.heat's `max`. With maxZoom:0 (intensity factor + * f=1) the accumulated per-cell value equals this count, so the densest visible + * cluster maps to the hot color — recomputed on every zoom/pan so it always + * normalizes within the current viewport. Returns at least 1. + */ +export function peakGridCount(coords: Array<[number, number]>, cellSize: number): number { + if (coords.length === 0) return 1 + const g = Math.max(1, cellSize) + const counts = new Map() + let peak = 1 + for (const [x, y] of coords) { + const key = `${Math.floor(x / g)}:${Math.floor(y / g)}` + const next = (counts.get(key) ?? 0) + 1 + counts.set(key, next) + if (next > peak) peak = next + } + return peak +} + +/** + * Convert location records to map points (for scatter layer). + */ +export function locationsToMapPoints(records: LocationRecord[]): LocationMapPoint[] { + return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r })) +} + +/** + * Convert poo records to map points (for scatter layer). + */ +export function pooToMapPoints(records: PooRecord[]): PooMapPoint[] { + return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r })) +} + +// --------------------------------------------------------------------------- +// Client-side time-window filter (for poo records — the endpoint has no server filter) +// --------------------------------------------------------------------------- + +/** + * Filter poo records to those whose timestamp falls within [start, end] (inclusive). + * start and end are ISO8601 strings (e.g. "2026-01-01T00:00:00Z"). + * If start or end is null, that bound is open (no filtering on that side). + */ +export function filterPooByTimeWindow( + records: PooRecord[], + start: string | null, + end: string | null, +): PooRecord[] { + if (!start && !end) return records + return records.filter((r) => { + const ts = r.timestamp + if (start && ts < start) return false + if (end && ts > end) return false + return true + }) +} + +// --------------------------------------------------------------------------- +// Default time window helpers +// --------------------------------------------------------------------------- + +/** Returns ISO8601 string for N days ago from now (UTC). */ +export function daysAgoISO(days: number): string { + const d = new Date() + d.setUTCDate(d.getUTCDate() - days) + return d.toISOString() +} + +/** Returns ISO8601 string for now (UTC). */ +export function nowISO(): string { + return new Date().toISOString() +} + +/** Compute a bounding center from an array of lat/lng points. Returns null if empty. */ +export function computeCenter( + points: Array<{ lat: number; lng: number }>, +): [number, number] | null { + if (points.length === 0) return null + const sumLat = points.reduce((s, p) => s + p.lat, 0) + const sumLng = points.reduce((s, p) => s + p.lng, 0) + return [sumLat / points.length, sumLng / points.length] +} + +// --------------------------------------------------------------------------- +// Quick time-range presets + window shifting (Grafana-style) +// --------------------------------------------------------------------------- + +const HOUR_MS = 3_600_000 +const DAY_MS = 24 * HOUR_MS + +/** A quick-range preset: a label + a span in milliseconds (month/year approximated). */ +export interface TimePreset { + value: string + label: string + spanMs: number +} + +export const TIME_PRESETS: TimePreset[] = [ + { value: '24h', label: 'Past 24 hours', spanMs: 24 * HOUR_MS }, + { value: '1w', label: 'Past 1 week', spanMs: 7 * DAY_MS }, + { value: '2w', label: 'Past 2 weeks', spanMs: 14 * DAY_MS }, + { value: '1mo', label: 'Past 1 month', spanMs: 30 * DAY_MS }, + { value: '6mo', label: 'Past 6 months', spanMs: 182 * DAY_MS }, + { value: '1y', label: 'Past 1 year', spanMs: 365 * DAY_MS }, + { value: '5y', label: 'Past 5 years', spanMs: 5 * 365 * DAY_MS }, +] + +/** ISO8601 with second precision, no milliseconds: "YYYY-MM-DDTHH:MM:SSZ". */ +function isoSeconds(d: Date): string { + return d.toISOString().slice(0, 19) + 'Z' +} + +/** + * Compute a [start, end] window of width `spanMs` ending at `now`. + * Used when the user picks a quick-range preset. + */ +export function presetRange(spanMs: number, now: Date = new Date()): { start: string; end: string } { + return { start: isoSeconds(new Date(now.getTime() - spanMs)), end: isoSeconds(now) } +} + +/** + * Shift a [start, end] window by its OWN span. direction = -1 moves earlier + * (back in time), +1 moves later. The window width is preserved. + */ +export function shiftRange( + startISO: string, + endISO: string, + direction: -1 | 1, +): { start: string; end: string } { + const startMs = Date.parse(startISO) + const endMs = Date.parse(endISO) + const span = endMs - startMs + return { + start: isoSeconds(new Date(startMs + direction * span)), + end: isoSeconds(new Date(endMs + direction * span)), + } +} diff --git a/frontend/src/map/timeRange.test.ts b/frontend/src/map/timeRange.test.ts new file mode 100644 index 0000000..7fd3552 --- /dev/null +++ b/frontend/src/map/timeRange.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for the quick-range preset + window-shift helpers (Grafana-style). + */ + +import { describe, it, expect } from 'vitest' +import { TIME_PRESETS, presetRange, shiftRange } from './mapUtils' + +describe('TIME_PRESETS', () => { + it('exposes the 7 expected quick ranges in order', () => { + expect(TIME_PRESETS.map((p) => p.value)).toEqual([ + '24h', + '1w', + '2w', + '1mo', + '6mo', + '1y', + '5y', + ]) + }) +}) + +describe('presetRange', () => { + const now = new Date('2026-06-13T12:00:00Z') + + it('ends at now and spans the given duration (24h)', () => { + const { start, end } = presetRange(24 * 3_600_000, now) + expect(end).toBe('2026-06-13T12:00:00Z') + expect(start).toBe('2026-06-12T12:00:00Z') + }) + + it('spans a week', () => { + const { start, end } = presetRange(7 * 24 * 3_600_000, now) + expect(end).toBe('2026-06-13T12:00:00Z') + expect(start).toBe('2026-06-06T12:00:00Z') + }) + + it('emits second-precision ISO with no milliseconds', () => { + const { start, end } = presetRange(3_600_000, now) + expect(start).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/) + expect(end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/) + }) +}) + +describe('shiftRange', () => { + it('moves a 24h window back by 24h when direction = -1', () => { + const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', -1) + expect(start).toBe('2026-06-11T12:00:00Z') + expect(end).toBe('2026-06-12T12:00:00Z') + }) + + it('moves a 24h window forward by 24h when direction = +1', () => { + const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', 1) + expect(start).toBe('2026-06-13T12:00:00Z') + expect(end).toBe('2026-06-14T12:00:00Z') + }) + + it('shifts by the window OWN span (a 1-week window moves a week)', () => { + const { start, end } = shiftRange('2026-06-06T12:00:00Z', '2026-06-13T12:00:00Z', -1) + expect(start).toBe('2026-05-30T12:00:00Z') + expect(end).toBe('2026-06-06T12:00:00Z') + }) + + it('is reversible: shift back then forward returns to the original window', () => { + const orig = { start: '2026-06-06T12:00:00Z', end: '2026-06-13T12:00:00Z' } + const back = shiftRange(orig.start, orig.end, -1) + const fwd = shiftRange(back.start, back.end, 1) + expect(fwd).toEqual(orig) + }) +}) diff --git a/frontend/src/pages/ChangePasswordPage.test.tsx b/frontend/src/pages/ChangePasswordPage.test.tsx new file mode 100644 index 0000000..f2919be --- /dev/null +++ b/frontend/src/pages/ChangePasswordPage.test.tsx @@ -0,0 +1,193 @@ +/** + * Tests for ChangePasswordPage (M2-T07 rework-1). + * + * Strategy: vi.mock the apiClient and useSession modules so we can control + * POST /api/auth/password responses and session state without a real server. + * + * Coverage: + * 1. Renders the change-password form when user has force_password_change=true. + * 2. Successful password change → navigates to '/' (proceeds into the app). + * 3. Client-side mismatch → shows error, does NOT call the API. + * 4. API 400 error → shows generic error, stays on form. + * 5. Guard: non-forced user visiting /change-password → redirected to '/'. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor, fireEvent } from '@testing-library/react' +import { renderWithProviders } from '../test-utils' +import { ChangePasswordPage } from './ChangePasswordPage' + +// --------------------------------------------------------------------------- +// Mock apiClient +// --------------------------------------------------------------------------- + +const mockPost = vi.fn() + +vi.mock('../api/client', () => ({ + default: { + POST: (...args: unknown[]) => mockPost(...args), + GET: vi.fn(), + }, + ApiError: class ApiError extends Error { + status: number + body: unknown + constructor(status: number, body: unknown) { + super(`API error ${status}`) + this.name = 'ApiError' + this.status = status + this.body = body + } + }, + registerLoginRedirect: vi.fn(), +})) + +// --------------------------------------------------------------------------- +// Mock useSession — default: forced-change user +// --------------------------------------------------------------------------- + +const mockUseSession = vi.fn(() => ({ + status: 'authenticated' as 'loading' | 'authenticated' | 'unauthenticated', + user: { username: 'admin', force_password_change: true } as + | null + | { username: string; force_password_change: boolean }, +})) + +vi.mock('../auth/SessionProvider', () => ({ + useSession: () => mockUseSession(), + SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderChangePw(initialPath = '/change-password') { + return renderWithProviders(, { + initialPath, + routes: [{ path: '/', element:
Home
}], + }) +} + +function fillAndSubmit(currentPw: string, newPw: string, confirmPw: string) { + fireEvent.change(screen.getByTestId('current-password-input'), { + target: { value: currentPw }, + }) + fireEvent.change(screen.getByTestId('new-password-input'), { + target: { value: newPw }, + }) + fireEvent.change(screen.getByTestId('confirm-password-input'), { + target: { value: confirmPw }, + }) + fireEvent.submit(screen.getByTestId('change-password-form')) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ChangePasswordPage', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: authenticated user with force_password_change=true + mockUseSession.mockReturnValue({ + status: 'authenticated', + user: { username: 'admin', force_password_change: true }, + }) + }) + + it('renders the change-password form for a forced-change user', () => { + renderChangePw() + expect(screen.getByTestId('change-password-form')).toBeInTheDocument() + expect(screen.getByTestId('current-password-input')).toBeInTheDocument() + expect(screen.getByTestId('new-password-input')).toBeInTheDocument() + expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument() + expect(screen.getByTestId('change-password-submit')).toBeInTheDocument() + }) + + it('navigates to "/" after a successful password change', async () => { + // Simulate successful POST /api/auth/password + mockPost.mockResolvedValueOnce({ + data: {}, + response: { status: 200, ok: true }, + }) + + renderChangePw() + fillAndSubmit('old-password', 'new-password', 'new-password') + + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument() + }) + }) + + it('calls POST /api/auth/password with the correct body', async () => { + mockPost.mockResolvedValueOnce({ + data: {}, + response: { status: 200, ok: true }, + }) + + renderChangePw() + fillAndSubmit('current123', 'newpass456', 'newpass456') + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith('/api/auth/password', { + body: { + current_password: 'current123', + new_password: 'newpass456', + confirm_password: 'newpass456', + }, + }) + }) + }) + + it('shows error and does NOT call the API when new passwords do not match', async () => { + renderChangePw() + fillAndSubmit('current-pw', 'new-pw-1', 'new-pw-2') + + await waitFor(() => { + expect(screen.getByTestId('change-password-error')).toBeInTheDocument() + }) + + expect(screen.getByTestId('change-password-error')).toHaveTextContent( + /do not match/i, + ) + expect(mockPost).not.toHaveBeenCalled() + // Should remain on the form + expect(screen.getByTestId('change-password-form')).toBeInTheDocument() + }) + + it('shows generic error on API 400 and stays on form', async () => { + // Simulate 400 via ApiError throw (as the client middleware does) + const { ApiError } = await import('../api/client') + mockPost.mockRejectedValueOnce(new ApiError(400, { detail: 'wrong password' })) + + renderChangePw() + fillAndSubmit('wrong-current', 'newpass', 'newpass') + + await waitFor(() => { + expect(screen.getByTestId('change-password-error')).toBeInTheDocument() + }) + + expect(screen.getByTestId('change-password-error')).toHaveTextContent( + /password change failed/i, + ) + // Should NOT have navigated away + expect(screen.getByTestId('change-password-form')).toBeInTheDocument() + }) + + it('redirects a non-forced user away from /change-password to "/"', async () => { + // A user who has already changed their password + mockUseSession.mockReturnValue({ + status: 'authenticated', + user: { username: 'admin', force_password_change: false }, + }) + + renderChangePw() + + await waitFor(() => { + expect(screen.getByTestId('home-page')).toBeInTheDocument() + }) + + // The change-password form must NOT be shown + expect(screen.queryByTestId('change-password-form')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/ChangePasswordPage.tsx b/frontend/src/pages/ChangePasswordPage.tsx new file mode 100644 index 0000000..88c0924 --- /dev/null +++ b/frontend/src/pages/ChangePasswordPage.tsx @@ -0,0 +1,168 @@ +/** + * ChangePasswordPage — forced password change gate (M2-T07). + * + * Shown when the authenticated user has force_password_change === true. + * Blocks access to all other pages until the password is changed. + * + * Behaviours: + * - If the current user does NOT have force_password_change, redirect to '/' + * (mirrors LoginPage's already-authenticated guard). + * - POST /api/auth/password with { current_password, new_password, confirm_password }. + * - On ApiError 400 → show a generic failure message (do not leak details). + * - On success → invalidate ['session'] so SessionProvider re-fetches with + * force_password_change=false, then navigate to '/' to enter the app. + */ + +import { useState } from 'react' +import { useNavigate, useLocation, Navigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { + Container, + Paper, + Title, + Text, + PasswordInput, + Button, + Alert, + Stack, + Center, +} from '@mantine/core' +import { useSession } from '../auth/SessionProvider' +import apiClient from '../api/client' +import { ApiError } from '../api/client' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface LocationState { + from?: { pathname: string } +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function ChangePasswordPage() { + const { user } = useSession() + const navigate = useNavigate() + const location = useLocation() + const queryClient = useQueryClient() + + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + // Guard: if the user is authenticated but NOT in forced-change state, redirect + // to the app. This prevents a non-forced user from sitting on /change-password. + // (Mirrors LoginPage's already-authenticated redirect.) + if (user && !user.force_password_change) { + const from = (location.state as LocationState)?.from?.pathname ?? '/' + return + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + // Client-side validation: confirm passwords match before hitting the server. + if (newPassword !== confirmPassword) { + setError('New passwords do not match.') + return + } + + setLoading(true) + + try { + await apiClient.POST('/api/auth/password', { + body: { + current_password: currentPassword, + new_password: newPassword, + confirm_password: confirmPassword, + }, + }) + + // Success: refresh session so force_password_change becomes false, + // then navigate into the app — the guard above (and ProtectedRoute) will + // no longer block access once the session is updated. + await queryClient.invalidateQueries({ queryKey: ['session'] }) + navigate('/', { replace: true }) + } catch (err) { + if (err instanceof ApiError && err.status === 400) { + // Generic failure message — do not leak backend detail. + setError('Password change failed. Please check your current password and try again.') + } else { + setError('An unexpected error occurred. Please try again.') + } + } finally { + setLoading(false) + } + } + + return ( +
+ + + + Change Password + + + You must change your password before continuing. + + + {error && ( + + {error} + + )} + +
+ + setCurrentPassword(e.currentTarget.value)} + required + autoComplete="current-password" + data-testid="current-password-input" + /> + + setNewPassword(e.currentTarget.value)} + required + autoComplete="new-password" + data-testid="new-password-input" + /> + + setConfirmPassword(e.currentTarget.value)} + required + autoComplete="new-password" + data-testid="confirm-password-input" + /> + + + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/ConfigPage.test.tsx b/frontend/src/pages/ConfigPage.test.tsx new file mode 100644 index 0000000..52ef314 --- /dev/null +++ b/frontend/src/pages/ConfigPage.test.tsx @@ -0,0 +1,337 @@ +/** + * Tests for ConfigPage (M2-T08). + * + * Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses + * without a real server. + * + * Coverage: + * 1. Renders config sections from a mocked GET /api/config response. + * 2. Secret fields start as empty (never display masked value). + * 3. Non-secret fields show their loaded values. + * 4. Save: updates map includes all non-secret fields and excludes untouched secrets. + * 5. Save: updates map includes a secret only when the user typed a new value. + * 6. Save success → shows success notice. + * 7. Save error → shows error notice. + * 8. SMTP test button: success state (200 result=success). + * 9. SMTP test button: config-error state (400/ApiError result=config-error). + * 10. SMTP test button: failed state (502/ApiError result=failed). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor, fireEvent } from '@testing-library/react' +import { renderWithProviders } from '../test-utils' +import { ConfigPage } from './ConfigPage' + +// --------------------------------------------------------------------------- +// Fixture: config sections +// --------------------------------------------------------------------------- + +const MOCK_CONFIG = { + sections: [ + { + name: 'General', + fields: [ + { env_name: 'APP_NAME', label: 'App Name', value: 'My Home', secret: false, input_type: 'text', configured: true }, + { env_name: 'APP_PORT', label: 'Port', value: '8000', secret: false, input_type: 'number', configured: true }, + ], + }, + { + name: 'SMTP', + fields: [ + { env_name: 'SMTP_HOST', label: 'SMTP Host', value: 'smtp.example.com', secret: false, input_type: 'text', configured: true }, + { env_name: 'SMTP_PASSWORD', label: 'SMTP Password', value: '', secret: true, input_type: 'password', configured: true }, + ], + }, + ], +} + +// --------------------------------------------------------------------------- +// Mock apiClient +// --------------------------------------------------------------------------- + +const mockGet = vi.fn() +const mockPut = vi.fn() +const mockPost = vi.fn() + +vi.mock('../api/client', () => ({ + default: { + GET: (...args: unknown[]) => mockGet(...args), + PUT: (...args: unknown[]) => mockPut(...args), + POST: (...args: unknown[]) => mockPost(...args), + }, + ApiError: class ApiError extends Error { + status: number + body: unknown + constructor(status: number, body: unknown) { + super(`API error ${status}`) + this.name = 'ApiError' + this.status = status + this.body = body + } + }, + registerLoginRedirect: vi.fn(), +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function renderConfig() { + return renderWithProviders(, { initialPath: '/config' }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ConfigPage', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: GET /api/config returns the fixture + mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } }) + }) + + // ------------------------------------------------------------------------- + // 1. Renders sections + // ------------------------------------------------------------------------- + + it('renders section names and field labels', async () => { + renderConfig() + + await waitFor(() => { + expect(screen.getByText('General')).toBeInTheDocument() + }) + + expect(screen.getByText('SMTP')).toBeInTheDocument() + expect(screen.getByText('App Name')).toBeInTheDocument() + expect(screen.getByText('SMTP Host')).toBeInTheDocument() + expect(screen.getByText('SMTP Password')).toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 2. Secret fields start empty + // ------------------------------------------------------------------------- + + it('renders secret fields with empty value (never displays masked value)', async () => { + renderConfig() + + await waitFor(() => { + expect(screen.getByText('SMTP Password')).toBeInTheDocument() + }) + + // Mantine puts data-testid on the element itself + const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement + expect(secretInput.value).toBe('') + }) + + // ------------------------------------------------------------------------- + // 3. Non-secret fields show their loaded values + // ------------------------------------------------------------------------- + + it('renders non-secret fields with their loaded values', async () => { + renderConfig() + + await waitFor(() => { + expect(screen.getByTestId('field-APP_NAME')).toBeInTheDocument() + }) + + // Mantine puts data-testid on the element itself for TextInput + const appNameInput = screen.getByTestId('field-APP_NAME') as HTMLInputElement + expect(appNameInput.value).toBe('My Home') + + const smtpHostInput = screen.getByTestId('field-SMTP_HOST') as HTMLInputElement + expect(smtpHostInput.value).toBe('smtp.example.com') + }) + + // ------------------------------------------------------------------------- + // 4. Save: updates includes all non-secrets, excludes untouched secrets + // ------------------------------------------------------------------------- + + it('save sends all non-secret fields and excludes untouched (blank) secrets', async () => { + mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } }) + // After save, refetch + mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } }) + + renderConfig() + + await waitFor(() => { + expect(screen.getByTestId('config-form')).toBeInTheDocument() + }) + + // Submit without touching any field + fireEvent.submit(screen.getByTestId('config-form')) + + await waitFor(() => { + expect(mockPut).toHaveBeenCalled() + }) + + const putCall = mockPut.mock.calls[0] + const body = putCall[1].body as { updates: Record } + const updates = body.updates + + // Non-secret fields MUST be present + expect(updates).toHaveProperty('APP_NAME', 'My Home') + expect(updates).toHaveProperty('APP_PORT', '8000') + expect(updates).toHaveProperty('SMTP_HOST', 'smtp.example.com') + + // Untouched secret field MUST NOT be present + expect(updates).not.toHaveProperty('SMTP_PASSWORD') + }) + + // ------------------------------------------------------------------------- + // 5. Save: updates includes secret when user typed a new value + // ------------------------------------------------------------------------- + + it('save includes a secret field when the user typed a new value', async () => { + mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } }) + mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } }) + + renderConfig() + + await waitFor(() => { + expect(screen.getByTestId('field-secret-SMTP_PASSWORD')).toBeInTheDocument() + }) + + // Mantine puts data-testid on the element itself + const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement + fireEvent.change(secretInput, { target: { value: 'new-secret-value' } }) + + fireEvent.submit(screen.getByTestId('config-form')) + + await waitFor(() => { + expect(mockPut).toHaveBeenCalled() + }) + + const putCall = mockPut.mock.calls[0] + const body = putCall[1].body as { updates: Record } + const updates = body.updates + + // Secret MUST be included because the user typed a value + expect(updates).toHaveProperty('SMTP_PASSWORD', 'new-secret-value') + // Non-secrets still present + expect(updates).toHaveProperty('APP_NAME', 'My Home') + }) + + // ------------------------------------------------------------------------- + // 6. Save success → shows success notice + // ------------------------------------------------------------------------- + + it('shows success alert after a successful save', async () => { + mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } }) + + renderConfig() + + await waitFor(() => { + expect(screen.getByTestId('config-form')).toBeInTheDocument() + }) + + fireEvent.submit(screen.getByTestId('config-form')) + + await waitFor(() => { + expect(screen.getByTestId('save-success')).toBeInTheDocument() + }) + + expect(screen.queryByTestId('save-error')).not.toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 7. Save error → shows error notice + // ------------------------------------------------------------------------- + + it('shows error alert when save fails', async () => { + const { ApiError } = await import('../api/client') + mockPut.mockRejectedValueOnce(new ApiError(422, { detail: 'invalid value' })) + + renderConfig() + + await waitFor(() => { + expect(screen.getByTestId('config-form')).toBeInTheDocument() + }) + + fireEvent.submit(screen.getByTestId('config-form')) + + await waitFor(() => { + expect(screen.getByTestId('save-error')).toBeInTheDocument() + }) + + expect(screen.queryByTestId('save-success')).not.toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 8. SMTP test button: success state + // ------------------------------------------------------------------------- + + it('shows success alert after SMTP test succeeds', async () => { + mockPost.mockResolvedValueOnce({ + data: { result: 'success', message: 'Email delivered.' }, + response: { status: 200, ok: true }, + }) + + renderConfig() + + await waitFor(() => { + expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('smtp-test-button')) + + await waitFor(() => { + expect(screen.getByTestId('smtp-result-success')).toBeInTheDocument() + }) + + expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument() + expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 9. SMTP test button: config-error state (400) + // ------------------------------------------------------------------------- + + it('shows config-error alert when SMTP test returns config-error', async () => { + const { ApiError } = await import('../api/client') + mockPost.mockRejectedValueOnce( + new ApiError(400, { result: 'config-error', message: 'SMTP host not configured.' }), + ) + + renderConfig() + + await waitFor(() => { + expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('smtp-test-button')) + + await waitFor(() => { + expect(screen.getByTestId('smtp-result-config-error')).toBeInTheDocument() + }) + + expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument() + expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument() + }) + + // ------------------------------------------------------------------------- + // 10. SMTP test button: failed state (502) + // ------------------------------------------------------------------------- + + it('shows failed alert when SMTP test returns failed', async () => { + const { ApiError } = await import('../api/client') + mockPost.mockRejectedValueOnce( + new ApiError(502, { result: 'failed', message: 'Connection refused.' }), + ) + + renderConfig() + + await waitFor(() => { + expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByTestId('smtp-test-button')) + + await waitFor(() => { + expect(screen.getByTestId('smtp-result-failed')).toBeInTheDocument() + }) + + expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument() + expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx new file mode 100644 index 0000000..760bf33 --- /dev/null +++ b/frontend/src/pages/ConfigPage.tsx @@ -0,0 +1,398 @@ +/** + * ConfigPage — config editor (M2-T08). + * + * Behaviours: + * 1. Load config: GET /api/config → render sections (grouped) with Mantine inputs. + * - Non-secret fields show their value. + * - Secret fields render as empty PasswordInput (never show a masked value). + * 2. Save config: PUT /api/config with full-field submission semantics. + * - All non-secret fields are ALWAYS included (to avoid backend zeroing absent fields). + * - Secret fields are included ONLY when the user typed a new (non-empty) value. + * - On success: show a success notice and refetch config. + * - On ApiError 422: show an error notice, nothing was written. + * 3. SMTP test button: POST /api/config/smtp/test. + * - Tri-state: success / config-error / failed. + * - Errors read `err.body.result` from ApiError. + */ + +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Container, + Title, + Text, + TextInput, + PasswordInput, + Button, + Alert, + Stack, + Group, + Divider, + Loader, + Center, + Paper, + Badge, +} from '@mantine/core' +import apiClient, { ApiError } from '../api/client' +import type { components } from '../api/schema.d.ts' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ConfigField = components['schemas']['ConfigField'] +type ConfigSection = components['schemas']['ConfigSection'] + +/** SMTP test result tri-state. */ +type SmtpResult = + | { kind: 'success'; message: string } + | { kind: 'config-error'; message: string } + | { kind: 'failed'; message: string } + | null + +// --------------------------------------------------------------------------- +// Hook: load config +// --------------------------------------------------------------------------- + +function useConfig() { + return useQuery({ + queryKey: ['config'], + queryFn: async () => { + const res = await apiClient.GET('/api/config') + return res.data + }, + }) +} + +// --------------------------------------------------------------------------- +// Helper: build updates map for PUT /api/config +// +// Full-field submission semantics (§6): +// - Non-secret fields: ALWAYS include current value (even if unchanged) so +// the backend does not zero out absent fields. +// - Secret fields: include ONLY when the user typed a non-empty value. +// Blank secret = keep old value; sending blank would also keep it per +// backend semantics, but we omit it to be explicit and avoid confusion. +// --------------------------------------------------------------------------- + +function buildUpdates( + sections: ConfigSection[], + localValues: Record, +): Record { + const updates: Record = {} + + for (const section of sections) { + for (const field of section.fields) { + const localVal = localValues[field.env_name] ?? '' + if (field.secret) { + // Only include secret if the user typed something (non-empty). + if (localVal !== '') { + updates[field.env_name] = localVal + } + // blank secret → omit → backend keeps the existing stored value + } else { + // Non-secret: always include current local value. + updates[field.env_name] = localVal + } + } + } + + return updates +} + +// --------------------------------------------------------------------------- +// ConfigFieldInput — renders a single config field +// --------------------------------------------------------------------------- + +interface ConfigFieldInputProps { + field: ConfigField + value: string + onChange: (envName: string, value: string) => void +} + +function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) { + const handleChange = (e: React.ChangeEvent) => { + onChange(field.env_name, e.currentTarget.value) + } + + if (field.secret) { + return ( + + ) + } + + if (field.input_type === 'number') { + return ( + + ) + } + + return ( + + ) +} + +// --------------------------------------------------------------------------- +// ConfigSectionPanel — one section +// --------------------------------------------------------------------------- + +interface ConfigSectionPanelProps { + section: ConfigSection + localValues: Record + onChange: (envName: string, value: string) => void +} + +function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) { + return ( + + + {section.name} + + + {section.fields.map((field) => ( + + ))} + + + ) +} + +// --------------------------------------------------------------------------- +// SmtpTestButton — sends POST /api/config/smtp/test and displays tri-state result +// --------------------------------------------------------------------------- + +interface SmtpTestButtonProps { + smtpResult: SmtpResult + setSmtpResult: (r: SmtpResult) => void +} + +function SmtpTestButton({ smtpResult, setSmtpResult }: SmtpTestButtonProps) { + const [testing, setTesting] = useState(false) + + async function handleTest() { + setSmtpResult(null) + setTesting(true) + try { + const res = await apiClient.POST('/api/config/smtp/test') + if (res.data) { + setSmtpResult({ kind: 'success', message: res.data.message }) + } + } catch (err) { + if (err instanceof ApiError) { + const body = err.body as { result?: string; message?: string } | null + const result = body?.result + const message = body?.message ?? 'Unknown error' + if (result === 'config-error') { + setSmtpResult({ kind: 'config-error', message }) + } else { + // result === 'failed' or any other error + setSmtpResult({ kind: 'failed', message }) + } + } else { + setSmtpResult({ kind: 'failed', message: 'Unexpected error sending test email.' }) + } + } finally { + setTesting(false) + } + } + + return ( + + + + {smtpResult?.kind === 'success' && ( + + Test email sent successfully. {smtpResult.message} + + )} + {smtpResult?.kind === 'config-error' && ( + + SMTP configuration error — check your SMTP settings. {smtpResult.message} + + )} + {smtpResult?.kind === 'failed' && ( + + Test email send failed. {smtpResult.message} + + )} + + ) +} + +// --------------------------------------------------------------------------- +// ConfigPage — main component +// --------------------------------------------------------------------------- + +export function ConfigPage() { + const queryClient = useQueryClient() + const { data, isLoading, isError } = useConfig() + + // Local field values — mirrors the loaded config but allows user edits. + // Secret fields always start as empty string (never display masked values). + const [localValues, setLocalValues] = useState>({}) + const [valuesInitialized, setValuesInitialized] = useState(false) + + // Initialise local state once when data arrives (or re-arrives after refetch). + if (data && !valuesInitialized) { + const initial: Record = {} + for (const section of data.sections) { + for (const field of section.fields) { + // Secret fields start empty (never display the masked/empty backend value). + initial[field.env_name] = field.secret ? '' : (field.value ?? '') + } + } + setLocalValues(initial) + setValuesInitialized(true) + } + + // Save notice state + const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null) + + // SMTP test tri-state + const [smtpResult, setSmtpResult] = useState(null) + + function handleChange(envName: string, value: string) { + setLocalValues((prev) => ({ ...prev, [envName]: value })) + setSaveStatus(null) + } + + const saveMutation = useMutation({ + mutationFn: async () => { + if (!data) return + const updates = buildUpdates(data.sections, localValues) + await apiClient.PUT('/api/config', { body: { updates } }) + }, + }) + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setSaveStatus(null) + try { + await saveMutation.mutateAsync() + setSaveStatus('success') + // Refetch config so the page reflects the saved state. + await queryClient.invalidateQueries({ queryKey: ['config'] }) + // After refetch, reset initialised flag so local state rebuilds from fresh data. + setValuesInitialized(false) + } catch { + setSaveStatus('error') + } + } + + // --------------------------------------------------------------------------- + // Render states + // --------------------------------------------------------------------------- + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError || !data) { + return ( + + + Failed to load configuration. Please refresh the page. + + + ) + } + + // Detect if there is an SMTP section (to show the test button). + const hasSmtpSection = data.sections.some((s) => + s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'), + ) + + return ( + + + Configuration + + {data.sections.length} section{data.sections.length !== 1 ? 's' : ''} + + + +
+ + {data.sections.map((section) => ( + + ))} + + + + {saveStatus === 'success' && ( + + Configuration saved successfully. + + )} + {saveStatus === 'error' && ( + + Failed to save configuration. Please check the values and try again. + + )} + + + + + {hasSmtpSection && ( + + )} + + +
+ + {!hasSmtpSection && ( + + + Configure SMTP settings to enable email notifications. + + + )} +
+ ) +} diff --git a/frontend/src/pages/HomePage.test.tsx b/frontend/src/pages/HomePage.test.tsx new file mode 100644 index 0000000..7728820 --- /dev/null +++ b/frontend/src/pages/HomePage.test.tsx @@ -0,0 +1,274 @@ +/** + * HomePage tests — M2-T09. + * + * Leaflet is mocked so jsdom doesn't choke on DOM APIs it doesn't support. + * We verify: + * 1. Controls render (time range inputs, layer toggles, apply button). + * 2. Point-select: when onSelectLocation is called, EditLocationModal opens. + * 3. Point-select: when onSelectPoo is called, EditPooModal opens. + * 4. The map component is rendered (mocked). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { MantineProvider } from '@mantine/core' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import type { ReactNode } from 'react' + +// --------------------------------------------------------------------------- +// Mock leaflet / react-leaflet before any component imports them. +// --------------------------------------------------------------------------- + +vi.mock('leaflet', () => ({ + default: {}, + Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } }, + DivIcon: vi.fn(() => ({})), + heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })), + markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), clearLayers: vi.fn() })), + marker: vi.fn(() => ({ + bindTooltip: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + })), + tileLayer: vi.fn(), + map: vi.fn(), +})) + +vi.mock('leaflet.heat', () => ({})) +vi.mock('leaflet.markercluster', () => ({})) + +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + TileLayer: () => null, + useMap: () => ({ + addLayer: vi.fn(), + removeLayer: vi.fn(), + hasLayer: vi.fn(() => false), + }), +})) + +// Mock leaflet image imports +vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' })) +vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' })) +vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' })) + +// Mock leaflet CSS +vi.mock('leaflet/dist/leaflet.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({})) +vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({})) + +// --------------------------------------------------------------------------- +// Mock RecordsMap to capture onSelectLocation / onSelectPoo callbacks +// --------------------------------------------------------------------------- + +import type { RecordsMapProps } from '../map/RecordsMap' + +let capturedOnSelectLocation: RecordsMapProps['onSelectLocation'] | undefined +let capturedOnSelectPoo: RecordsMapProps['onSelectPoo'] | undefined + +vi.mock('../map/RecordsMap', () => ({ + RecordsMap: (props: RecordsMapProps) => { + capturedOnSelectLocation = props.onSelectLocation + capturedOnSelectPoo = props.onSelectPoo + return
+ }, +})) + +// --------------------------------------------------------------------------- +// Mock apiClient — return minimal data so queries resolve +// --------------------------------------------------------------------------- + +vi.mock('../api/client', () => ({ + default: { + GET: vi.fn(async (path: string) => { + if (path === '/api/locations') { + return { + data: { + items: [ + { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: null, + }, + ], + limit: 5000, + offset: 0, + }, + } + } + if (path === '/api/poo') { + return { + data: { + items: [ + { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, + }, + ], + limit: 1000, + offset: 0, + }, + } + } + return { data: null } + }), + }, +})) + +// --------------------------------------------------------------------------- +// Now import components under test (after mocks are registered) +// --------------------------------------------------------------------------- + +import { HomePage } from './HomePage' + +// --------------------------------------------------------------------------- +// Test wrapper +// --------------------------------------------------------------------------- + +function makeQC() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function Wrapper({ qc, children }: { qc: QueryClient; children: ReactNode }) { + return ( + + + {children} + + + ) +} + +// Helper: render HomePage and wait for queries to resolve +async function renderHomePage() { + const qc = makeQC() + const utils = render( + + + , + ) + // Wait for the map mock to appear (data loaded) + await waitFor(() => screen.getByTestId('records-map-mock')) + return utils +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('HomePage', () => { + beforeEach(() => { + capturedOnSelectLocation = undefined + capturedOnSelectPoo = undefined + }) + + it('renders time-range controls', async () => { + await renderHomePage() + expect(screen.getByTestId('time-start-input')).toBeTruthy() + expect(screen.getByTestId('time-end-input')).toBeTruthy() + expect(screen.getByTestId('apply-window-button')).toBeTruthy() + }) + + it('renders layer toggle switches', async () => { + await renderHomePage() + expect(screen.getByTestId('toggle-location-heat')).toBeTruthy() + expect(screen.getByTestId('toggle-poo-heat')).toBeTruthy() + expect(screen.getByTestId('toggle-scatter')).toBeTruthy() + }) + + it('renders the RecordsMap component', async () => { + await renderHomePage() + expect(screen.getByTestId('records-map-mock')).toBeTruthy() + }) + + it('opens EditLocationModal when onSelectLocation is called with a location record', async () => { + await renderHomePage() + + // Simulate clicking a location scatter point + const record = { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: null, + } + expect(capturedOnSelectLocation).toBeDefined() + capturedOnSelectLocation!(record) + + // EditLocationModal should appear + await waitFor(() => screen.getByTestId('edit-location-modal')) + expect(screen.getByTestId('edit-location-modal')).toBeTruthy() + }) + + it('opens EditPooModal when onSelectPoo is called with a poo record', async () => { + await renderHomePage() + + const record = { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, + } + expect(capturedOnSelectPoo).toBeDefined() + capturedOnSelectPoo!(record) + + await waitFor(() => screen.getByTestId('edit-poo-modal')) + expect(screen.getByTestId('edit-poo-modal')).toBeTruthy() + }) + + it('closes EditLocationModal when Cancel is clicked', async () => { + await renderHomePage() + + const record = { + person: 'alice', + datetime: '2026-01-15T10:00:00Z', + latitude: 39.9, + longitude: 116.4, + altitude: null, + } + capturedOnSelectLocation!(record) + await waitFor(() => screen.getByTestId('edit-location-modal')) + + fireEvent.click(screen.getByTestId('edit-location-cancel')) + await waitFor(() => expect(screen.queryByTestId('edit-location-modal')).toBeNull()) + }) + + it('closes EditPooModal when Cancel is clicked', async () => { + await renderHomePage() + + const record = { + timestamp: '2026-01-20T09:00:00Z', + status: 'done', + latitude: 39.91, + longitude: 116.41, + } + capturedOnSelectPoo!(record) + await waitFor(() => screen.getByTestId('edit-poo-modal')) + + fireEvent.click(screen.getByTestId('edit-poo-cancel')) + await waitFor(() => expect(screen.queryByTestId('edit-poo-modal')).toBeNull()) + }) + + it('time-range inputs have default values', async () => { + await renderHomePage() + const startInput = screen.getByTestId('time-start-input') as HTMLInputElement + const endInput = screen.getByTestId('time-end-input') as HTMLInputElement + expect(startInput.value).toBeTruthy() + expect(endInput.value).toBeTruthy() + }) + + it('Apply button re-triggers data fetch with new window', async () => { + await renderHomePage() + const startInput = screen.getByTestId('time-start-input') as HTMLInputElement + fireEvent.change(startInput, { target: { value: '2026-01-01T00:00' } }) + fireEvent.click(screen.getByTestId('apply-window-button')) + // Just verify no crash; data refresh happens async via React Query. + await waitFor(() => screen.getByTestId('records-map-mock')) + }) +}) diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..8f20e8b --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,406 @@ +/** + * HomePage — data-visualization map view (M2-T09). + * + * Renders a heat map of location records (where you've been) and poo records + * (where the dog poops), plus a toggleable scatter layer for point-select + * edit/delete (reusing T10's modals + hooks). + * + * Data fetching and all state live here; the map itself is fully isolated in + * src/map/RecordsMap.tsx (the ONLY place that imports leaflet). + */ + +import { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + Stack, + Group, + Switch, + TextInput, + Button, + Select, + ActionIcon, + Tooltip, + Paper, + Text, + Box, + Loader, + Alert, + Badge, + useComputedColorScheme, +} from '@mantine/core' +import { ChevronLeft, ChevronRight } from 'react-feather' + +import apiClient from '../api/client' +import { + locationsToHeatPoints, + pooToHeatPoints, + locationsToMapPoints, + pooToMapPoints, + filterPooByTimeWindow, + daysAgoISO, + nowISO, + TIME_PRESETS, + presetRange, + shiftRange, +} from '../map' +import { RecordsMap } from '../map' +import { + EditLocationModal, + EditPooModal, + ConfirmDeleteModal, + useDeleteLocation, + useDeletePoo, +} from '../records' +import type { LocationRecord, PooRecord } from '../records' + +// --------------------------------------------------------------------------- +// Data hooks (query-key prefix: ['locations', ...] / ['poo', ...]) +// --------------------------------------------------------------------------- + +function useLocations(start: string | null, end: string | null) { + return useQuery({ + queryKey: ['locations', { start, end, limit: 5000 }], + queryFn: async () => { + const res = await apiClient.GET('/api/locations', { + params: { + query: { + limit: 5000, + offset: 0, + ...(start ? { start } : {}), + ...(end ? { end } : {}), + }, + }, + }) + return res.data?.items ?? [] + }, + }) +} + +/** + * Poo endpoint has no server-side time filter — fetch a large page (max 1000) + * and client-filter by timestamp below. + */ +function usePoo() { + return useQuery({ + queryKey: ['poo', { limit: 1000 }], + queryFn: async () => { + const res = await apiClient.GET('/api/poo', { + params: { query: { limit: 1000, offset: 0 } }, + }) + return res.data?.items ?? [] + }, + }) +} + +// --------------------------------------------------------------------------- +// Point-select state (which record is selected + which modal to show) +// --------------------------------------------------------------------------- + +type SelectionState = + | { kind: 'none' } + | { kind: 'editLocation'; record: LocationRecord } + | { kind: 'deleteLocation'; record: LocationRecord } + | { kind: 'editPoo'; record: PooRecord } + | { kind: 'deletePoo'; record: PooRecord } + +// --------------------------------------------------------------------------- +// HomePage +// --------------------------------------------------------------------------- + +export function HomePage() { + // ------ Time-window state ----------------------------------------------- + // Default: last 30 days → now + const [startInput, setStartInput] = useState(() => { + const d = new Date() + d.setUTCDate(d.getUTCDate() - 30) + return d.toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM" + }) + const [endInput, setEndInput] = useState(() => nowISO().slice(0, 16)) + // Applied (committed) window — updated on Apply / preset / shift + const [appliedStart, setAppliedStart] = useState(() => daysAgoISO(30)) + const [appliedEnd, setAppliedEnd] = useState(() => nowISO()) + // Which quick-range preset is currently active (null = custom / shifted range) + const [activePreset, setActivePreset] = useState(null) + + // Set both the committed window and the editable inputs from an ISO [start, end]. + function setWindow(startISO: string, endISO: string) { + setAppliedStart(startISO) + setAppliedEnd(endISO) + setStartInput(startISO.slice(0, 16)) + setEndInput(endISO.slice(0, 16)) + } + + // Pick a quick range: fill from-to ending at now, apply immediately (Grafana-style). + function applyPreset(value: string | null) { + const preset = TIME_PRESETS.find((p) => p.value === value) + if (!preset) return + const { start, end } = presetRange(preset.spanMs) + setWindow(start, end) + setActivePreset(value) + } + + // Shift the committed window by its own span. -1 = earlier, +1 = later. + function shiftWindow(direction: -1 | 1) { + if (!appliedStart || !appliedEnd) return + const { start, end } = shiftRange(appliedStart, appliedEnd, direction) + setWindow(start, end) + // A shifted window is an absolute range, no longer "now - X". + setActivePreset(null) + } + + // ------ Layer toggle state ----------------------------------------------- + const [showLocationHeat, setShowLocationHeat] = useState(true) + const [showPooHeat, setShowPooHeat] = useState(true) + const [showScatter, setShowScatter] = useState(false) + + // ------ Data fetching ---------------------------------------------------- + const locationsQuery = useLocations(appliedStart, appliedEnd) + const pooQuery = usePoo() + + // Client-side time-filter for poo (server has no filter) + const filteredPoo = useMemo( + () => filterPooByTimeWindow(pooQuery.data ?? [], appliedStart, appliedEnd), + [pooQuery.data, appliedStart, appliedEnd], + ) + + // Derived map data + const locationHeatPoints = useMemo( + () => locationsToHeatPoints(locationsQuery.data ?? []), + [locationsQuery.data], + ) + const pooHeatPoints = useMemo( + () => pooToHeatPoints(filteredPoo), + [filteredPoo], + ) + const locationScatterPoints = useMemo( + () => locationsToMapPoints(locationsQuery.data ?? []), + [locationsQuery.data], + ) + const pooScatterPoints = useMemo( + () => pooToMapPoints(filteredPoo), + [filteredPoo], + ) + + // ------ Point-select state ----------------------------------------------- + const [selection, setSelection] = useState({ kind: 'none' }) + + const deleteLocationMut = useDeleteLocation() + const deletePooMut = useDeletePoo() + + // Handlers + function handleSelectLocation(record: LocationRecord) { + setSelection({ kind: 'editLocation', record }) + } + function handleSelectPoo(record: PooRecord) { + setSelection({ kind: 'editPoo', record }) + } + + function applyWindow() { + // Convert local datetime-local inputs (which have no TZ) to ISO8601 + // by appending :00Z if needed. Input is "YYYY-MM-DDTHH:MM". + const toISO = (s: string) => (s ? s + ':00Z' : null) + setAppliedStart(toISO(startInput)) + setAppliedEnd(toISO(endInput)) + // Manually-applied range is custom, not a preset. + setActivePreset(null) + } + + // ------ Render ----------------------------------------------------------- + const isLoading = locationsQuery.isLoading || pooQuery.isLoading + const isError = locationsQuery.isError || pooQuery.isError + const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true }) + + return ( + + {/* Controls bar */} + + + {/* Time-range row */} + + setStartInput(e.currentTarget.value)} + size="xs" + style={{ minWidth: 180 }} + data-testid="time-start-input" + /> + setEndInput(e.currentTarget.value)} + size="xs" + style={{ minWidth: 180 }} + data-testid="time-end-input" + /> + {/* Quick range + shift buttons (Grafana-style) — between To and Apply. + zIndex raised above Leaflet (~1000) so the dropdown/tooltips are + not painted over by the map below. */} + +