diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml new file mode 100644 index 00000000..c452c8c1 --- /dev/null +++ b/.github/workflows/docker-dev.yml @@ -0,0 +1,188 @@ +name: Build & Push Docker Image (Prerelease) + +on: + push: + branches: [dev] + paths-ignore: + - 'docs/**' + - '**/*.md' + workflow_dispatch: + inputs: + bump: + description: 'Bump line for next prerelease (auto detects in-flight major)' + type: choice + options: [auto, minor, major] + default: auto + +permissions: + contents: write + +concurrency: + group: prerelease-build + cancel-in-progress: false + +jobs: + version-bump: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.bump.outputs.VERSION }} + sha: ${{ steps.bump.outputs.SHA }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine prerelease version + id: bump + run: | + git fetch --tags + + # Capture the exact commit we're building so build/merge jobs are pinned to it + echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + # Get latest stable tag (exclude prerelease tags) + STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1) + STABLE="${STABLE_TAG#v}" + STABLE="${STABLE:-0.0.0}" + echo "Latest stable: $STABLE" + + IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE" + + # Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found. + NEXT_MAJOR="$((MAJOR + 1)).0.0" + MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1) + + BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}" + + if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then + TARGET="$NEXT_MAJOR" + else + TARGET="${MAJOR}.$((MINOR + 1)).0" + fi + echo "Target: $TARGET" + + # Find the highest existing prerelease N for this target and increment + LAST_N=$(git tag -l "v${TARGET}-pre.*" | sed 's/.*-pre\.//' | sort -n | tail -1) + N=$(( ${LAST_N:-0} + 1 )) + + NEW_VERSION="${TARGET}-pre.${N}" + echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "$STABLE → $NEW_VERSION" + + build: + runs-on: ${{ matrix.runner }} + needs: version-bump + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - name: Prepare platform tag-safe name + run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV + + - uses: actions/checkout@v4 + with: + ref: ${{ needs.version-bump.outputs.sha }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true + no-cache: true + build-args: | + APP_VERSION=${{ needs.version-bump.outputs.version }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: [version-bump, build] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.version-bump.outputs.sha }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download build digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create and push multi-arch manifest + working-directory: /tmp/digests + run: | + VERSION="${{ needs.version-bump.outputs.version }}" + mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) + MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre" + docker buildx imagetools create \ + -t "mauriceboe/trek:latest-pre" \ + -t "mauriceboe/trek:$MAJOR_TAG" \ + -t "mauriceboe/trek:$VERSION" \ + "${digests[@]}" + + - name: Inspect manifest + run: docker buildx imagetools inspect mauriceboe/trek:latest-pre + + - name: Push git tag + run: | + VERSION="${{ needs.version-bump.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "v$VERSION" + git push origin "v$VERSION" + + - name: Clean up old prerelease tags + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + KEEP=20 + VERSION="${{ needs.version-bump.outputs.version }}" + BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')" + git fetch --tags + # Sort by numeric prerelease N (field after -pre.) to get correct ascending order + mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}') + TOTAL=${#ALL_TAGS[@]} + DELETE_COUNT=$((TOTAL - KEEP)) + if [ "$DELETE_COUNT" -gt 0 ]; then + for TAG in "${ALL_TAGS[@]:0:$DELETE_COUNT}"; do + echo "Deleting old prerelease tag: $TAG" + git push origin --delete "$TAG" + done + fi diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0a7c8f38..a8bbd3fa 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -7,10 +7,24 @@ on: - 'docs/**' - '**/*.md' workflow_dispatch: + inputs: + bump: + description: 'Force bump line (auto = patch/finalize as today)' + type: choice + options: [auto, patch, minor, major] + default: auto + confirm_major: + description: "Type MAJOR (all caps) to confirm a major release" + type: string + default: '' permissions: contents: write +concurrency: + group: stable-build + cancel-in-progress: false + jobs: version-bump: runs-on: ubuntu-latest @@ -20,48 +34,79 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + fetch-tags: true token: ${{ secrets.GITHUB_TOKEN }} - name: Determine bump type and update version id: bump run: | - # Check if this push is a merge commit from dev branch - COMMIT_MSG=$(git log -1 --pretty=%s) - PARENT_COUNT=$(git log -1 --pretty=%p | wc -w) + git fetch --tags - if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then + # Derive version from git tags — no package.json dependency + STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1) + STABLE="${STABLE_TAG#v}" + STABLE="${STABLE:-0.0.0}" + + PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1) + + BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE" + + if [ "$BUMP_INPUT" = "major" ]; then + if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then + echo "::error::confirm_major must equal 'MAJOR' to cut a major release" + exit 1 + fi + NEW_VERSION="$((MAJOR + 1)).0.0" + BUMP="major" + elif [ "$BUMP_INPUT" = "minor" ]; then + NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" BUMP="minor" - elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then - BUMP="minor" - else + elif [ "$BUMP_INPUT" = "patch" ]; then + NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" BUMP="patch" + else + # auto: finalize in-flight prerelease if one exists, else patch + if [ -n "$PRE_TAG" ]; then + PRE_BASE="${PRE_TAG#v}" + PRE_BASE="${PRE_BASE%-pre.*}" + PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)" + # Refuse to auto-finalize a major bump — it bypasses confirm_major + if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then + echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize." + exit 1 + fi + # If prerelease base is strictly greater than stable, finalize it + HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1) + if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then + NEW_VERSION="$PRE_BASE" + BUMP="finalize" + else + PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + BUMP="patch" + fi + else + PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + BUMP="patch" + fi fi echo "Bump type: $BUMP" - - # Read current version - CURRENT=$(node -p "require('./server/package.json').version") - IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" - - if [ "$BUMP" = "minor" ]; then - MINOR=$((MINOR + 1)) - PATCH=0 - else - PATCH=$((PATCH + 1)) - fi - - NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT - echo "$CURRENT → $NEW_VERSION ($BUMP)" + echo "$STABLE → $NEW_VERSION ($BUMP)" - # Update both package.json files + # Update package.json files and Helm chart cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd .. cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd .. + sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml + sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml # Commit and tag git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add server/package.json server/package-lock.json client/package.json client/package-lock.json + git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml git commit -m "chore: bump version to $NEW_VERSION [skip ci]" git tag "v$NEW_VERSION" git push origin main --follow-tags @@ -100,6 +145,8 @@ jobs: platforms: ${{ matrix.platform }} outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true no-cache: true + build-args: | + APP_VERSION=${{ needs.version-bump.outputs.version }} - name: Export digest run: | @@ -140,14 +187,29 @@ jobs: - name: Create and push multi-arch manifest working-directory: /tmp/digests run: | - VERSION=${{ needs.version-bump.outputs.version }} + VERSION="${{ needs.version-bump.outputs.version }}" mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) + MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)" docker buildx imagetools create \ - -t mauriceboe/trek:latest \ - -t mauriceboe/trek:$VERSION \ - -t mauriceboe/nomad:latest \ - -t mauriceboe/nomad:$VERSION \ + -t "mauriceboe/trek:latest" \ + -t "mauriceboe/trek:$MAJOR_TAG" \ + -t "mauriceboe/trek:$VERSION" \ "${digests[@]}" - name: Inspect manifest run: docker buildx imagetools inspect mauriceboe/trek:latest + + release-helm: + runs-on: ubuntu-latest + needs: version-bump + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + + - name: Publish Helm chart + uses: stefanprodan/helm-gh-pages@v1.7.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + charts_dir: charts diff --git a/.github/workflows/enforce-target-branch.yml b/.github/workflows/enforce-target-branch.yml index d8027ccb..7a326a3f 100644 --- a/.github/workflows/enforce-target-branch.yml +++ b/.github/workflows/enforce-target-branch.yml @@ -1,7 +1,7 @@ name: Enforce PR Target Branch on: - pull_request: + pull_request_target: types: [opened, reopened, edited, synchronize] jobs: @@ -9,6 +9,8 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + issues: write + contents: read steps: - name: Flag or clear wrong base branch @@ -63,14 +65,16 @@ jobs: repo: context.repo.repo, name: 'wrong-base-branch', }); - } catch { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: 'wrong-base-branch', - color: 'd73a4a', - description: 'PR is targeting the wrong base branch', - }); + } catch (err) { + if (err.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: 'wrong-base-branch', + color: 'd73a4a', + description: 'PR is targeting the wrong base branch', + }); + } } await github.rest.issues.addLabels({ diff --git a/Dockerfile b/Dockerfile index 1dd5707b..44c3d531 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,8 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/ ENV NODE_ENV=production ENV PORT=3000 +ARG APP_VERSION=dev +ENV APP_VERSION=${APP_VERSION} EXPOSE 3000 diff --git a/MCP.md b/MCP.md index d6db9fa6..cbd924ad 100644 --- a/MCP.md +++ b/MCP.md @@ -9,6 +9,10 @@ structured API. ## Table of Contents - [Setup](#setup) + - [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended) + - [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated) +- [Authentication](#authentication) +- [OAuth Scopes](#oauth-scopes) - [Limitations & Important Notes](#limitations--important-notes) - [Resources (read-only)](#resources-read-only) - [Tools (read-write)](#tools-read-write) @@ -22,22 +26,51 @@ structured API. ### 1. Enable the MCP addon (admin) An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp` -endpoint returns `403 Forbidden` and the MCP section does not appear in user settings. +endpoint returns `404` and the MCP section does not appear in user settings. -### 2. Create an API token +### 2. Connect your MCP client -Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token: +#### Option A: OAuth 2.1 (recommended) -1. Click **Create New Token** -2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop") -3. **Copy the token immediately** — it is shown only once and cannot be recovered +MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token +management required — just provide the server URL: -Each user can create up to **10 tokens**. +```json +{ + "mcpServers": { + "trek": { + "command": "npx", + "args": [ + "mcp-remote", + "https://your-trek-instance.com/mcp" + ] + } + } +} +``` -### 3. Configure your MCP client +> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows). -The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your -`claude_desktop_config.json`: +**What happens automatically:** +1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server. +2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591). +3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant. +4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed. + +> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth +> discovery to work correctly. + +**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in +**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret +(`trekcs_` prefix) and fixed scopes that you define up front. + +#### Option B: Static API Token (deprecated) + +> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above. + +1. Go to **Settings > Integrations > MCP** and create an API token. +2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once. +3. Add it to your `claude_desktop_config.json`: ```json { @@ -55,7 +88,65 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau } ``` -> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows). +Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a +static token will receive deprecation warnings in the AI client via server instructions and tool results. + +Each user can create up to **10 static tokens**. + +--- + +## Authentication + +TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients. + +| Method | Token prefix | Access level | TTL | Notes | +|--------|-------------|-------------|-----|-------| +| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. | +| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. | +| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. | + +All methods require the `Authorization: Bearer ` header (strict scheme enforcement — `Bearer` required). + +--- + +## OAuth Scopes + +When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools +that match your granted scopes for that session. + +| Scope | Permission | Group | +|-------|-----------|-------| +| `trips:read` | View trips & itineraries | Trips | +| `trips:write` | Edit trips & itineraries | Trips | +| `trips:delete` | Delete trips (irreversible) | Trips | +| `trips:share` | Manage share links | Trips | +| `places:read` | View places & map data | Places | +| `places:write` | Manage places | Places | +| `atlas:read` | View Atlas | Atlas | +| `atlas:write` | Manage Atlas | Atlas | +| `packing:read` | View packing lists | Packing | +| `packing:write` | Manage packing lists | Packing | +| `todos:read` | View to-do lists | To-dos | +| `todos:write` | Manage to-do lists | To-dos | +| `budget:read` | View budget | Budget | +| `budget:write` | Manage budget | Budget | +| `reservations:read` | View reservations | Reservations | +| `reservations:write` | Manage reservations | Reservations | +| `collab:read` | View collaboration | Collaboration | +| `collab:write` | Manage collaboration | Collaboration | +| `notifications:read` | View notifications | Notifications | +| `notifications:write` | Manage notifications | Notifications | +| `vacay:read` | View vacation plans | Vacation | +| `vacay:write` | Manage vacation plans | Vacation | +| `geo:read` | Maps & geocoding | Geo | +| `weather:read` | Weather forecasts | Weather | + +**Scope rules:** +- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access). +- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access. +- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools. +- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes). +- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled. --- @@ -68,10 +159,13 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau | **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. | | **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. | | **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. | -| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. | -| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. | -| **Token limits** | Maximum 10 API tokens per user. | -| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. | +| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. | +| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. | +| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. | +| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. | +| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. | +| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. | +| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. | | **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. | | **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. | @@ -356,11 +450,12 @@ trip in a single call. MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks. -| Prompt | Description | -|-------------------|---------------------------------------------------------------------------------| -| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. | -| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. | -| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. | +| Prompt | Description | +|----------------------|---------------------------------------------------------------------------------| +| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. | +| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. | +| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. | +| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. | --- diff --git a/README.md b/README.md index c701df0c..46a969a5 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Discord + Discord License: AGPL v3 Docker Pulls GitHub Stars @@ -77,7 +77,8 @@ - **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user ### AI / MCP Integration -- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips +- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips +- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access - **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation - **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context - **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled @@ -97,11 +98,23 @@ - **PWA**: vite-plugin-pwa + Workbox - **Real-Time**: WebSocket (`ws`) - **State**: Zustand -- **Auth**: JWT + OIDC + TOTP (MFA) +- **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA) - **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional) - **Weather**: Open-Meteo API (free, no key required) - **Icons**: lucide-react +## Helm (Kubernetes) + +A hosted Helm repository is available: + +```sh +helm repo add trek https://mauriceboe.github.io/TREK +helm repo update +helm install trek trek/trek +``` + +See [`charts/README.md`](charts/README.md) for configuration options. + ## Quick Start ```bash @@ -148,17 +161,18 @@ services: - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs). - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details + # - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - - FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy - # - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production. - - TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For + # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy + # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended. + # - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work. # - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs) - APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links # - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL # - OIDC_CLIENT_ID=trek # OpenID Connect client ID # - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret # - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button - # - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only) + # - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime. # - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users # - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role # - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM) @@ -166,8 +180,8 @@ services: # - DEMO_MODE=false # Enable demo mode (resets data hourly) # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist # - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist - # - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) - # - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5) + # - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300) + # - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20) volumes: - ./data:/app/data - ./uploads:/app/uploads @@ -180,7 +194,13 @@ services: start_period: 15s ``` -This example is aimed at reverse-proxy deployments. If you access TREK directly on `http://:3000` without nginx, Caddy, Traefik, or another TLS-terminating proxy in front of it, set `FORCE_HTTPS=false` and remove `TRUST_PROXY` to avoid redirects to a non-existent HTTPS endpoint. +This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together: + +- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy. +- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set. +- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments. + +If you access TREK directly on `http://:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint. ```bash docker compose up -d @@ -253,6 +273,9 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; + # File uploads are capped at 50 MB; backup restore ZIPs can include the full + # uploads directory and may exceed that — raise this value if restores fail. + client_max_body_size 500m; } location / { @@ -290,10 +313,11 @@ trek.yourdomain.com { | `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | +| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | -| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy. If you access TREK directly on `http://host:3000`, keep this `false`. | `false` | -| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` | -| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For`. Use this only when TREK is actually behind a reverse proxy. | `1` | +| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` | +| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) | +| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` | | `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — | | **OIDC / SSO** | | | @@ -301,7 +325,7 @@ trek.yourdomain.com { | `OIDC_CLIENT_ID` | OIDC client ID | — | | `OIDC_CLIENT_SECRET` | OIDC client secret | — | | `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` | -| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` | +| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` | | `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — | | `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — | | `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` | @@ -311,8 +335,8 @@ trek.yourdomain.com { | `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random | | **Other** | | | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | -| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` | -| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` | +| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | +| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` | ## Optional API Keys diff --git a/chart/README.md b/charts/README.md similarity index 68% rename from chart/README.md rename to charts/README.md index 87fc2fc2..b5380f85 100644 --- a/chart/README.md +++ b/charts/README.md @@ -10,8 +10,20 @@ This is a minimal Helm chart for deploying the TREK app. - Optional generic Ingress support - Health checks on `/api/health` +## Helm Repository + +A hosted Helm repository is available: + +```sh +helm repo add trek https://mauriceboe.github.io/TREK +helm repo update +helm install trek trek/trek +``` + ## Usage +Or install directly from the local chart: + ```sh helm install trek ./chart \ --set ingress.enabled=true \ @@ -32,5 +44,7 @@ See `values.yaml` for more options. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. - Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless. -- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases. +- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set. +- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically. +- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.** - Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path. diff --git a/chart/Chart.yaml b/charts/trek/Chart.yaml similarity index 65% rename from chart/Chart.yaml rename to charts/trek/Chart.yaml index 886ba48f..914f839d 100644 --- a/chart/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 0.1.0 +version: 2.9.13 description: Minimal Helm chart for TREK app -appVersion: "latest" +appVersion: "2.9.13" diff --git a/chart/templates/NOTES.txt b/charts/trek/templates/NOTES.txt similarity index 100% rename from chart/templates/NOTES.txt rename to charts/trek/templates/NOTES.txt diff --git a/chart/templates/_helpers.tpl b/charts/trek/templates/_helpers.tpl similarity index 100% rename from chart/templates/_helpers.tpl rename to charts/trek/templates/_helpers.tpl diff --git a/chart/templates/configmap.yaml b/charts/trek/templates/configmap.yaml similarity index 100% rename from chart/templates/configmap.yaml rename to charts/trek/templates/configmap.yaml diff --git a/chart/templates/deployment.yaml b/charts/trek/templates/deployment.yaml similarity index 98% rename from chart/templates/deployment.yaml rename to charts/trek/templates/deployment.yaml index 0ab074ba..d79ae344 100644 --- a/chart/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -27,7 +27,7 @@ spec: fsGroup: 1000 containers: - name: trek - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} {{- with .Values.resources }} resources: diff --git a/chart/templates/ingress.yaml b/charts/trek/templates/ingress.yaml similarity index 100% rename from chart/templates/ingress.yaml rename to charts/trek/templates/ingress.yaml diff --git a/chart/templates/pvc.yaml b/charts/trek/templates/pvc.yaml similarity index 100% rename from chart/templates/pvc.yaml rename to charts/trek/templates/pvc.yaml diff --git a/chart/templates/secret.yaml b/charts/trek/templates/secret.yaml similarity index 100% rename from chart/templates/secret.yaml rename to charts/trek/templates/secret.yaml diff --git a/chart/templates/service.yaml b/charts/trek/templates/service.yaml similarity index 100% rename from chart/templates/service.yaml rename to charts/trek/templates/service.yaml diff --git a/chart/values.yaml b/charts/trek/values.yaml similarity index 75% rename from chart/values.yaml rename to charts/trek/values.yaml index 47a941c7..42c86b1f 100644 --- a/chart/values.yaml +++ b/charts/trek/values.yaml @@ -1,7 +1,7 @@ image: repository: mauriceboe/trek - tag: latest + # tag: latest pullPolicy: IfNotPresent # Optional image pull secrets for private registries @@ -19,17 +19,21 @@ env: # Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin). # LOG_LEVEL: "info" # "info" = concise user actions, "debug" = verbose details. + # DEFAULT_LANGUAGE: "en" + # Default language on the login page for users with no saved preference. + # Browser/OS language is auto-detected first; this is the fallback when no match is found. + # Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar # ALLOWED_ORIGINS: "" # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. # APP_URL: "https://trek.example.com" # Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP. # Also used as the base URL for links in email notifications and other external links. # FORCE_HTTPS: "false" - # Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy. + # Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY. # COOKIE_SECURE: "true" - # Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production. + # Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production. # TRUST_PROXY: "1" - # Number of trusted reverse proxies for X-Forwarded-For header parsing. + # Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work. # ALLOW_INTERNAL_NETWORK: "false" # Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address. # Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked. @@ -40,7 +44,9 @@ env: # OIDC_DISPLAY_NAME: "SSO" # Label shown on the SSO login button. # OIDC_ONLY: "false" - # Set to "true" to disable local password auth entirely (first SSO login becomes admin). + # Set to "true" to force SSO-only mode: disables password login and password registration. + # Overrides the granular toggles in Admin > Settings and cannot be changed at runtime. + # First SSO login becomes admin on a fresh instance. # OIDC_ADMIN_CLAIM: "" # OIDC claim used to identify admin users. # OIDC_ADMIN_VALUE: "" @@ -51,10 +57,10 @@ env: # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik). # DEMO_MODE: "false" # Enable demo mode (hourly data resets). - # MCP_RATE_LIMIT: "60" - # Max MCP API requests per user per minute. Defaults to 60. - # MCP_MAX_SESSION_PER_USER: "5" - # Max concurrent MCP sessions per user. Defaults to 5. + # MCP_RATE_LIMIT: "300" + # Max MCP API requests per user per minute. Defaults to 300. + # MCP_MAX_SESSION_PER_USER: "20" + # Max concurrent MCP sessions per user. Defaults to 20. # Secret environment variables stored in a Kubernetes Secret. diff --git a/client/index.html b/client/index.html index 582bc34f..0e50cf50 100644 --- a/client/index.html +++ b/client/index.html @@ -2,7 +2,7 @@ - + TREK diff --git a/client/package-lock.json b/client/package-lock.json index f1ac488c..42ad8db8 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,17 +1,19 @@ { "name": "trek-client", - "version": "2.9.12", + "version": "2.9.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trek-client", - "version": "2.9.12", + "version": "2.9.13", "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", + "dexie": "^4.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", + "marked": "^18.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.4.1", @@ -20,6 +22,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^6.22.2", "react-window": "^2.2.7", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "zustand": "^4.5.2" @@ -33,8 +36,9 @@ "@types/react-dom": "^18.2.19", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.2.1", - "@vitest/coverage-v8": "^4.1.2", + "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.18", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "msw": "^2.13.0", "postcss": "^8.4.35", @@ -43,7 +47,7 @@ "typescript": "^6.0.2", "vite": "^5.1.4", "vite-plugin-pwa": "^0.21.0", - "vitest": "^4.1.2" + "vitest": "^3.2.4" } }, "node_modules/@adobe/css-tools": { @@ -66,15 +70,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@apideck/better-ajv-errors": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", - "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", "dev": true, "license": "MIT", "dependencies": { - "json-schema": "^0.4.0", - "jsonpointer": "^5.0.0", + "jsonpointer": "^5.0.1", "leven": "^3.1.0" }, "engines": { @@ -85,9 +102,9 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.6.tgz", - "integrity": "sha512-BXWCh8dHs9GOfpo/fWGDJtDmleta2VePN9rn6WQt3GjEbxzutVF4t0x2pmH+7dbMCLtuv3MlwqRsAuxlzFXqFg==", + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz", + "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==", "dev": true, "license": "MIT", "dependencies": { @@ -101,9 +118,9 @@ } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.7.tgz", - "integrity": "sha512-d2BgqDUOS1Hfp4IzKUZqCNz+Kg3Y88AkaBvJK/ZVSQPU1f7OpPNi7nQTH6/oI47Dkdg+Z3e8Yp6ynOu4UMINAQ==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", + "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", "dev": true, "license": "MIT", "dependencies": { @@ -154,6 +171,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1722,9 +1740,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -1746,9 +1764,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -1763,7 +1781,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.0" }, "engines": { "node": ">=20.19.0" @@ -1789,6 +1807,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1797,9 +1816,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", "dev": true, "funding": [ { @@ -1837,27 +1856,15 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -1865,16 +1872,395 @@ "tslib": "^2.4.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" } }, "node_modules/@exodus/bytes": { @@ -2043,6 +2429,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2080,6 +2469,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2175,6 +2567,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2224,6 +2619,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2388,13 +2786,62 @@ } }, "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, "engines": { - "node": ">=18" + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/@jridgewell/gen-mapping": { @@ -2476,23 +2923,28 @@ "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", - "dev": true, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "engines": { + "node": "^14.21.3 || >=16" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/@nodelib/fs.scandir": { @@ -2558,14 +3010,15 @@ "dev": true, "license": "MIT" }, - "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "optional": true, + "engines": { + "node": ">=14" } }, "node_modules/@react-leaflet/core": { @@ -2580,19 +3033,19 @@ } }, "node_modules/@react-pdf/fns": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.2.tgz", - "integrity": "sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.3.tgz", + "integrity": "sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==", "license": "MIT" }, "node_modules/@react-pdf/font": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.4.tgz", - "integrity": "sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-4.0.6.tgz", + "integrity": "sha512-1RxR/hTyZcbgjESUjrMms574xuS9PLB4ovqQx6jvgdrIHXUyeUtSH6i3Szw1qVfUnA9MfaEm1FBuydQeJD39BQ==", "license": "MIT", "dependencies": { - "@react-pdf/pdfkit": "^4.1.0", - "@react-pdf/types": "^2.9.2", + "@react-pdf/pdfkit": "^5.0.0", + "@react-pdf/types": "^2.10.0", "fontkit": "^2.0.2", "is-url": "^1.2.4" } @@ -2608,34 +3061,36 @@ } }, "node_modules/@react-pdf/layout": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.4.2.tgz", - "integrity": "sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.5.1.tgz", + "integrity": "sha512-1V8ssgg9FHVsmvuCKmp7TWoUiPGgxAR2cgyvdcao8UQm7emWB7rP1o4CieHH56kgZyXXbwWqQAmmtgvcju+xfA==", "license": "MIT", "dependencies": { - "@react-pdf/fns": "3.1.2", + "@react-pdf/fns": "3.1.3", "@react-pdf/image": "^3.0.4", - "@react-pdf/primitives": "^4.1.1", - "@react-pdf/stylesheet": "^6.1.2", - "@react-pdf/textkit": "^6.1.0", - "@react-pdf/types": "^2.9.2", + "@react-pdf/primitives": "^4.2.0", + "@react-pdf/stylesheet": "^6.1.4", + "@react-pdf/textkit": "^6.2.0", + "@react-pdf/types": "^2.10.0", "emoji-regex-xs": "^1.0.0", "queue": "^6.0.1", "yoga-layout": "^3.2.1" } }, "node_modules/@react-pdf/pdfkit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.1.0.tgz", - "integrity": "sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-5.0.0.tgz", + "integrity": "sha512-FcQBWGtfhMGuOB0G3NcnF/cBq/JnFVs22i1tuafiT1XlmG6KjCxgTGng5bVh+b9RtTuwNpUGmCtB6CmG6B4ZVA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", "@react-pdf/png-js": "^3.0.0", "browserify-zlib": "^0.2.0", - "crypto-js": "^4.2.0", "fontkit": "^2.0.2", "jay-peg": "^1.1.1", + "js-md5": "^0.8.3", "linebreak": "^1.1.0", "vite-compatible-readable-stream": "^3.6.1" } @@ -2650,9 +3105,9 @@ } }, "node_modules/@react-pdf/primitives": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz", - "integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.2.0.tgz", + "integrity": "sha512-onlXLcA6SpsD7SX9HOyt55qdRRJCfauegPlo4ZNw0hA/IipaZTbT9MJliWKtEXm03ibGxAQyp/BgTuXm91fo0A==", "license": "MIT" }, "node_modules/@react-pdf/reconciler": { @@ -2668,23 +3123,17 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@react-pdf/reconciler/node_modules/scheduler": { - "version": "0.25.0-rc-603e6108-20241029", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", - "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", - "license": "MIT" - }, "node_modules/@react-pdf/render": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.3.2.tgz", - "integrity": "sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.4.1.tgz", + "integrity": "sha512-TBaEw6F+IBI4oVHUF7LL2OJX87unRrk6r7mkEmgjehN9BV5LF53I8CzVtdAchuO1+YhvE4MoMzkNelA+X2luRA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", - "@react-pdf/fns": "3.1.2", - "@react-pdf/primitives": "^4.1.1", - "@react-pdf/textkit": "^6.1.0", - "@react-pdf/types": "^2.9.2", + "@react-pdf/fns": "3.1.3", + "@react-pdf/primitives": "^4.2.0", + "@react-pdf/textkit": "^6.2.0", + "@react-pdf/types": "^2.10.0", "abs-svg-path": "^0.1.1", "color-string": "^1.9.1", "normalize-svg-path": "^1.1.0", @@ -2693,20 +3142,20 @@ } }, "node_modules/@react-pdf/renderer": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.3.2.tgz", - "integrity": "sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.4.1.tgz", + "integrity": "sha512-mK7xyCdDUagO1kg8jraad3aUzdVAGBru08qyjjp8FMhGsh4BcuPGa0SycQ8Pv8EDEdyEOfmiE+XI1sBybSLwaQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.13", - "@react-pdf/fns": "3.1.2", - "@react-pdf/font": "^4.0.4", - "@react-pdf/layout": "^4.4.2", - "@react-pdf/pdfkit": "^4.1.0", - "@react-pdf/primitives": "^4.1.1", + "@react-pdf/fns": "3.1.3", + "@react-pdf/font": "^4.0.6", + "@react-pdf/layout": "^4.5.1", + "@react-pdf/pdfkit": "^5.0.0", + "@react-pdf/primitives": "^4.2.0", "@react-pdf/reconciler": "^2.0.0", - "@react-pdf/render": "^4.3.2", - "@react-pdf/types": "^2.9.2", + "@react-pdf/render": "^4.4.1", + "@react-pdf/types": "^2.10.0", "events": "^3.3.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", @@ -2717,13 +3166,13 @@ } }, "node_modules/@react-pdf/stylesheet": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.2.tgz", - "integrity": "sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.1.4.tgz", + "integrity": "sha512-jiwovO7lUwgccAh3JbVcXnh90AiSKZetdz2ETcWsKApPPLzLUzPkEs6wCVvZqh3lcGOAPFV3AfdMkFnLwv1ryg==", "license": "MIT", "dependencies": { - "@react-pdf/fns": "3.1.2", - "@react-pdf/types": "^2.9.2", + "@react-pdf/fns": "3.1.3", + "@react-pdf/types": "^2.10.0", "color-string": "^1.9.1", "hsl-to-hex": "^1.0.0", "media-engine": "^1.0.3", @@ -2731,26 +3180,26 @@ } }, "node_modules/@react-pdf/textkit": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.1.0.tgz", - "integrity": "sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-6.2.0.tgz", + "integrity": "sha512-0B22Kue/ALHiEcYNbrx2BdkpHPTq2j3u2xmAyCnf3XJbTyANjljJjtWRohkVLQKqOlieD88BvmQt7OeWLj+ZYg==", "license": "MIT", "dependencies": { - "@react-pdf/fns": "3.1.2", + "@react-pdf/fns": "3.1.3", "bidi-js": "^1.0.2", "hyphen": "^1.6.4", "unicode-properties": "^1.4.1" } }, "node_modules/@react-pdf/types": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.9.2.tgz", - "integrity": "sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.10.0.tgz", + "integrity": "sha512-iz0NusqQ/9ZHQirWhJqOaxY1UkpvuNkEDtH4/SPCnhZJKBO/IhlFLFHuzbHkmWByBoX6X3m8GCc2b/1QH6QNlA==", "license": "MIT", "dependencies": { - "@react-pdf/font": "^4.0.4", - "@react-pdf/primitives": "^4.1.1", - "@react-pdf/stylesheet": "^6.1.2" + "@react-pdf/font": "^4.0.6", + "@react-pdf/primitives": "^4.2.0", + "@react-pdf/stylesheet": "^6.1.4" } }, "node_modules/@remix-run/router": { @@ -2762,279 +3211,6 @@ "node": ">=14.0.0" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.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", @@ -3113,6 +3289,13 @@ } } }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -3127,9 +3310,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -3141,9 +3324,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -3155,9 +3338,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -3169,9 +3352,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -3183,9 +3366,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -3197,9 +3380,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -3211,9 +3394,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -3228,9 +3411,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -3245,9 +3428,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -3262,9 +3445,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -3279,9 +3462,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -3296,9 +3479,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], @@ -3313,9 +3496,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], @@ -3330,9 +3513,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], @@ -3347,9 +3530,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], @@ -3364,9 +3547,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], @@ -3381,9 +3564,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], @@ -3398,13 +3581,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3412,13 +3598,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3426,9 +3615,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -3440,9 +3629,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -3454,9 +3643,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -3468,9 +3657,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -3482,9 +3671,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -3496,9 +3685,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -3509,13 +3698,6 @@ "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/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -3529,10 +3711,20 @@ "string.prototype.matchall": "^4.0.6" } }, + "node_modules/@surma/rollup-plugin-off-main-thread/node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, "node_modules/@swc/helpers": { - "version": "0.5.19", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", - "integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -3628,24 +3820,12 @@ "@testing-library/dom": ">=7.21.4" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3786,6 +3966,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3797,6 +3978,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3866,29 +4048,32 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.2.tgz", - "integrity": "sha512-sPK//PHO+kAkScb8XITeB1bf7fsk85Km7+rt4eeuRR3VS1/crD47cmV5wicisJmjNdfeokTZwjMk4Mj2d58Mgg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", "dependencies": { + "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.2", - "ast-v8-to-istanbul": "^1.0.0", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3897,96 +4082,115 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", - "pathe": "^2.0.3" + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", - "magic-string": "^0.30.21", + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/snapshot/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==", + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", - "dev": true, - "license": "MIT", + "tinyspy": "^4.0.3" + }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4017,6 +4221,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4044,7 +4249,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -4140,9 +4344,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", "dev": true, "license": "MIT", "dependencies": { @@ -4151,16 +4355,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/ast-v8-to-istanbul/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/ast-v8-to-istanbul/node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -4211,9 +4405,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "dev": true, "funding": [ { @@ -4231,8 +4425,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -4264,14 +4458,14 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/babel-plugin-polyfill-corejs2": { @@ -4357,9 +4551,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", - "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4436,9 +4630,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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": [ { @@ -4455,12 +4649,13 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "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" @@ -4476,16 +4671,26 @@ "dev": true, "license": "MIT" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "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.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "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": { @@ -4536,9 +4741,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001780", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", - "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", "dev": true, "funding": [ { @@ -4567,11 +4772,18 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, "engines": { "node": ">=18" } @@ -4616,6 +4828,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4695,6 +4917,41 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -4857,12 +5114,6 @@ "node": ">= 8" } }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", - "license": "MIT" - }, "node_modules/crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -4927,44 +5178,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/data-urls/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/data-urls/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/data-urls/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/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5056,6 +5269,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -5143,6 +5366,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dexie": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.2.tgz", + "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==", + "license": "Apache-2.0" + }, "node_modules/dfa": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", @@ -5168,8 +5397,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -5185,6 +5413,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -5202,16 +5437,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.313", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", - "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, @@ -5235,9 +5470,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "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": { @@ -5322,9 +5557,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, @@ -5373,6 +5608,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5406,11 +5680,14 @@ } }, "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } }, "node_modules/esutils": { "version": "2.0.3", @@ -5447,6 +5724,16 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5547,9 +5834,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -5583,9 +5870,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -5846,26 +6133,23 @@ } }, "node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": "20 || >=22" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -5883,6 +6167,39 @@ "node": ">=10.13.0" } }, + "node_modules/glob/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/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -6738,6 +7055,21 @@ "node": ">=10" } }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -6753,19 +7085,19 @@ } }, "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" + "@isaacs/cliui": "^8.0.2" }, "funding": { "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jake": { @@ -6801,10 +7133,17 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6812,14 +7151,14 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", - "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.3", + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", @@ -6853,53 +7192,15 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.1.tgz", - "integrity": "sha512-Y71HWT4hydF1IAG/2OPync4dgQ/J2iWye7eg6CuzJHI+E97tvqFPlADzxiNnjH6WSljg8ecfXMr9k6bfFuqA5w==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, - "node_modules/jsdom/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/jsdom/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/jsdom/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/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6913,13 +7214,6 @@ "node": ">=6" } }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true, - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -6967,7 +7261,8 @@ "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" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/leaflet.markercluster": { "version": "1.5.3", @@ -6988,279 +7283,6 @@ "node": ">=6" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -7343,6 +7365,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7368,31 +7397,30 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } }, "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "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": { - "sourcemap-codec": "^1.4.8" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" } }, "node_modules/make-dir": { @@ -7434,6 +7462,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz", + "integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7644,6 +7684,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-newline-to-break": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", + "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-find-and-replace": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-phrasing": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", @@ -8345,13 +8399,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "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.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -8377,12 +8431,13 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.0.tgz", - "integrity": "sha512-5PPWf7I7DBHb4ZUZ0NUI+/VBDk/eiNYDNJZGt/jZ7+rbCSIK5hRcNTGqWMnn0vT6NrHiQlb0nfpenVGz1vrqpg==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.3.tgz", + "integrity": "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w==", "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", @@ -8395,7 +8450,7 @@ "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", - "rettime": "^0.10.1", + "rettime": "^0.11.7", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", @@ -8421,22 +8476,6 @@ } } }, - "node_modules/msw/node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -8479,9 +8518,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "dev": true, "license": "MIT" }, @@ -8567,17 +8606,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -8678,31 +8706,28 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } + "license": "ISC" }, "node_modules/path-to-regexp": { "version": "6.3.0", @@ -8718,6 +8743,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8769,9 +8804,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "dev": true, "funding": [ { @@ -8788,6 +8823,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8949,7 +8985,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8959,14 +8994,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8978,6 +9005,12 @@ "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/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -8989,10 +9022,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -9049,6 +9085,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -9061,6 +9098,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -9069,6 +9107,15 @@ "react": "^18.3.1" } }, + "node_modules/react-dom/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/react-dropzone": { "version": "14.4.1", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", @@ -9087,9 +9134,10 @@ } }, "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==", + "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": { @@ -9097,6 +9145,7 @@ "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", + "peer": true, "dependencies": { "@react-leaflet/core": "^2.1.0" }, @@ -9327,9 +9376,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -9339,6 +9388,21 @@ "regjsparser": "bin/parser" } }, + "node_modules/remark-breaks": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", + "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-newline-to-break": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -9425,12 +9489,13 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -9452,9 +9517,9 @@ "license": "MIT" }, "node_modules/rettime": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", - "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.7.tgz", + "integrity": "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==", "dev": true, "license": "MIT" }, @@ -9469,53 +9534,13 @@ "node": ">=0.10.0" } }, - "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", - "dev": true, - "license": "MIT" - }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9527,31 +9552,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -9668,13 +9693,10 @@ } }, "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" - } + "version": "0.25.0-rc-603e6108-20241029", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", + "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -9842,14 +9864,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "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.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9981,6 +10003,35 @@ "node": ">=0.10.0" } }, + "node_modules/source-map/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/source-map/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/source-map/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, "node_modules/sourcemap-codec": { "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", @@ -10017,9 +10068,9 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, @@ -10054,6 +10105,25 @@ } }, "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", @@ -10068,6 +10138,26 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -10185,6 +10275,23 @@ } }, "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -10197,6 +10304,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -10220,6 +10340,26 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -10380,6 +10520,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tempy/node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/terser": { "version": "5.46.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", @@ -10406,6 +10559,21 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -10443,24 +10611,21 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10493,6 +10658,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10500,10 +10666,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", "engines": { @@ -10577,13 +10763,16 @@ } }, "node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "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.1.0" + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" } }, "node_modules/trim-lines": { @@ -10620,13 +10809,16 @@ "license": "0BSD" }, "node_modules/type-fest": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", - "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", "dev": true, "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10716,6 +10908,7 @@ "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10744,9 +10937,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -11034,6 +11227,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11102,6 +11296,29 @@ "node": ">= 6" } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-pwa": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", @@ -11133,502 +11350,67 @@ } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", "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", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.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.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "jsdom": "*" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@opentelemetry/api": { + "@types/debug": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { + "@vitest/browser": { "optional": true }, "@vitest/ui": { @@ -11639,59 +11421,9 @@ }, "jsdom": { "optional": true - }, - "vite": { - "optional": false } } }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.2", - "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/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/vitest/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/vitest/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -11705,84 +11437,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/vite": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.5.tgz", - "integrity": "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "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/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -11797,11 +11451,14 @@ } }, "node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "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" + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } }, "node_modules/whatwg-mimetype": { "version": "5.0.0", @@ -11814,15 +11471,18 @@ } }, "node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "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": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" + "@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": { @@ -12017,6 +11677,16 @@ "node": ">=20.0.0" } }, + "node_modules/workbox-build/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -12087,6 +11757,84 @@ "dev": true, "license": "MIT" }, + "node_modules/workbox-build/node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/workbox-build/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/workbox-build/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/workbox-build/node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/workbox-build/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -12106,6 +11854,7 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -12268,6 +12017,76 @@ "node": ">=8" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/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/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -12284,6 +12103,41 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -12347,6 +12201,41 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", diff --git a/client/package.json b/client/package.json index b4533f7d..de4bf795 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "trek-client", - "version": "2.9.12", + "version": "2.9.13", "private": true, "type": "module", "scripts": { @@ -17,8 +17,10 @@ "dependencies": { "@react-pdf/renderer": "^4.3.2", "axios": "^1.6.7", + "dexie": "^4.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.344.0", + "marked": "^18.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.4.1", @@ -27,6 +29,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^6.22.2", "react-window": "^2.2.7", + "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "zustand": "^4.5.2" @@ -40,8 +43,9 @@ "@types/react-dom": "^18.2.19", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.2.1", - "@vitest/coverage-v8": "^4.1.2", + "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.18", + "fake-indexeddb": "^6.2.5", "jsdom": "^29.0.1", "msw": "^2.13.0", "postcss": "^8.4.35", @@ -50,6 +54,6 @@ "typescript": "^6.0.2", "vite": "^5.1.4", "vite-plugin-pwa": "^0.21.0", - "vitest": "^4.1.2" + "vitest": "^3.2.4" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index 0ca00b63..90e82cdf 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -10,13 +10,20 @@ import AdminPage from './pages/AdminPage' import SettingsPage from './pages/SettingsPage' import VacayPage from './pages/VacayPage' import AtlasPage from './pages/AtlasPage' +import JourneyPage from './pages/JourneyPage' +import JourneyDetailPage from './pages/JourneyDetailPage' +import JourneyPublicPage from './pages/JourneyPublicPage' import SharedTripPage from './pages/SharedTripPage' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' +import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import { ToastContainer } from './components/shared/Toast' +import BottomNav from './components/Layout/BottomNav' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' +import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers' +import OfflineBanner from './components/Layout/OfflineBanner' interface ProtectedRouteProps { children: ReactNode @@ -60,7 +67,12 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps return } - return <>{children} + return ( +

+
{children}
+ +
+ ) } function RootRedirect() { @@ -78,16 +90,26 @@ function RootRedirect() { } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() useEffect(() => { - if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) { - loadUser() + if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) { + // If the persist snapshot already has an authenticated user, validate + // silently so the PWA shell renders immediately without a spinner. + const alreadyAuthenticated = useAuthStore.getState().isAuthenticated + if (alreadyAuthenticated) { + useAuthStore.setState({ isLoading: false }) + loadUser({ silent: true }) + } else { + loadUser() + } } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record }) => { if (config?.demo_mode) setDemoMode(true) if (config?.dev_mode) setDevMode(true) + if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) + if (config?.version) setAppVersion(config.version) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) if (config?.timezone) setServerTimezone(config.timezone) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) @@ -126,6 +148,11 @@ export default function App() { } }, [isAuthenticated]) + useEffect(() => { + registerSyncTriggers() + return () => unregisterSyncTriggers() + }, []) + const location = useLocation() const isSharedPage = location.pathname.startsWith('/shared/') @@ -158,11 +185,15 @@ export default function App() { return ( + } /> } /> } /> + } /> } /> + {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} + } /> } /> + + + + } + /> + + + + } + /> > = { + en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar, +} + +function translateRateLimit(): string { + const fallback = 'Too many attempts. Please try again later.' + try { + const lang = localStorage.getItem('app_language') || 'en' + const table = rateLimitTranslations[lang] || rateLimitTranslations.en + return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback + } catch { + return fallback + } +} + +export const apiClient: AxiosInstance = axios.create({ baseURL: '/api', withCredentials: true, headers: { @@ -9,24 +38,36 @@ const apiClient: AxiosInstance = axios.create({ }, }) -// Request interceptor - add socket ID +const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete']) + +// Request interceptor - add socket ID + idempotency key for mutating requests apiClient.interceptors.request.use( (config) => { const sid = getSocketId() if (sid) { config.headers['X-Socket-Id'] = sid } + // Attach a per-request idempotency key to all write operations so the + // server can deduplicate retried requests (e.g. network blips). + // The mutation queue sets its own pre-generated key; skip if already set. + const method = (config.method ?? '').toLowerCase() + if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) { + const key = typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + config.headers['X-Idempotency-Key'] = key + } return config }, (error) => Promise.reject(error) ) -// Response interceptor - handle 401 +// Response interceptor - handle 401, 403 MFA, 429 rate limit apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { - if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) { + if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) { const currentPath = window.location.pathname + window.location.search window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) } @@ -38,6 +79,16 @@ apiClient.interceptors.response.use( ) { window.location.href = '/settings?mfa=required' } + if (error.response?.status === 429) { + const translated = translateRateLimit() + const data = error.response.data as { error?: string } | undefined + if (data && typeof data === 'object') { + data.error = translated + } else { + error.response.data = { error: translated } + } + error.message = translated + } return Promise.reject(error) } ) @@ -72,6 +123,43 @@ export const authApi = { }, } +export const oauthApi = { + /** Validate OAuth authorize params — called by consent page on load */ + validate: (params: { + response_type: string + client_id: string + redirect_uri: string + scope: string + state?: string + code_challenge: string + code_challenge_method: string + }) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data), + + /** Submit user consent (approve or deny) */ + authorize: (body: { + client_id: string + redirect_uri: string + scope: string + state?: string + code_challenge: string + code_challenge_method: string + approved: boolean + }) => apiClient.post('/oauth/authorize', body).then(r => r.data), + + clients: { + list: () => apiClient.get('/oauth/clients').then(r => r.data), + create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) => + apiClient.post('/oauth/clients', data).then(r => r.data), + rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data), + delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data), + }, + + sessions: { + list: () => apiClient.get('/oauth/sessions').then(r => r.data), + revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data), + }, +} + export const tripsApi = { list: (params?: Record) => apiClient.get('/trips', { params }).then(r => r.data), create: (data: Record) => apiClient.post('/trips', data).then(r => r.data), @@ -85,6 +173,7 @@ export const tripsApi = { addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data), removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data), copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data), + bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data), } export const daysApi = { @@ -105,8 +194,14 @@ export const placesApi = { const fd = new FormData(); fd.append('file', file) return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, + importMapFile: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, importGoogleList: (tripId: number | string, url: string) => apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), + importNaverList: (tripId: number | string, url: string) => + apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data), } export const assignmentsApi = { @@ -195,6 +290,8 @@ export const adminApi = { apiClient.get('/admin/audit-log', { params }).then(r => r.data), mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data), deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), + oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data), + revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data), getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data), updatePermissions: (permissions: Record) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), @@ -208,8 +305,56 @@ export const addonsApi = { enabled: () => apiClient.get('/addons').then(r => r.data), } +export const journeyApi = { + list: () => apiClient.get('/journeys').then(r => r.data), + create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data), + get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data), + update: (id: number, data: Record) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data), + delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data), + + suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data), + availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data), + + // Trips (sync sources) + addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data), + removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data), + + // Entries + listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data), + createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), + updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), + deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), + + // Photos + uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data), + addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data), + linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data), + updatePhoto: (photoId: number, data: Record) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data), + deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data), + + // Cover + uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + + // Contributors + addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data), + updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data), + removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data), + + // Preferences + updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data), + + // Share + getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), + createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data), + deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data), + getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data), +} + export const mapsApi = { search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), + autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) => + apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data), @@ -258,6 +403,11 @@ export const weatherApi = { getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data), } +export const configApi = { + getPublicConfig: (): Promise<{ defaultLanguage: string }> => + apiClient.get('/config').then(r => r.data), +} + export const settingsApi = { get: () => apiClient.get('/settings').then(r => r.data), set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), diff --git a/client/src/api/oauthScopes.test.ts b/client/src/api/oauthScopes.test.ts new file mode 100644 index 00000000..b16da606 --- /dev/null +++ b/client/src/api/oauthScopes.test.ts @@ -0,0 +1,102 @@ +// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010 +import { describe, it, expect } from 'vitest' +import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes' + +describe('SCOPE_GROUPS', () => { + it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => { + const expected = [ + 'trips:read', 'trips:write', 'trips:delete', 'trips:share', + 'places:read', 'places:write', + 'atlas:read', 'atlas:write', + 'packing:read', 'packing:write', + 'todos:read', 'todos:write', + 'budget:read', 'budget:write', + 'reservations:read', 'reservations:write', + 'collab:read', 'collab:write', + 'notifications:read', 'notifications:write', + 'vacay:read', 'vacay:write', + 'geo:read', 'weather:read', + ] + for (const scope of expected) { + expect(SCOPE_GROUPS).toHaveProperty(scope) + } + }) + + it('FE-OAUTH-SCOPES-002: each scope entry has labelKey, descriptionKey, groupKey', () => { + for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) { + expect(keys.labelKey, `${scope} missing labelKey`).toBeTruthy() + expect(keys.descriptionKey, `${scope} missing descriptionKey`).toBeTruthy() + expect(keys.groupKey, `${scope} missing groupKey`).toBeTruthy() + } + }) +}) + +describe('ALL_SCOPES', () => { + it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => { + expect(ALL_SCOPES).toHaveLength(24) + }) + + it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => { + expect(ALL_SCOPES).toEqual(Object.keys(SCOPE_GROUPS)) + }) +}) + +describe('SCOPE_GROUP_NAMES', () => { + it('FE-OAUTH-SCOPES-005: contains no duplicate group names', () => { + expect(SCOPE_GROUP_NAMES).toHaveLength(new Set(SCOPE_GROUP_NAMES).size) + }) + + it('FE-OAUTH-SCOPES-006: contains expected groups', () => { + const expected = [ + 'oauth.scope.group.trips', + 'oauth.scope.group.places', + 'oauth.scope.group.packing', + 'oauth.scope.group.budget', + ] + for (const g of expected) { + expect(SCOPE_GROUP_NAMES).toContain(g) + } + }) +}) + +describe('getScopesByGroup', () => { + const identity = (key: string) => key + + it('FE-OAUTH-SCOPES-007: groups all scopes under the correct group key', () => { + const groups = getScopesByGroup(identity) + // Every scope must appear exactly once across all groups + const allScopesInGroups = Object.values(groups).flat().map(s => s.scope) + expect(allScopesInGroups).toHaveLength(ALL_SCOPES.length) + for (const scope of ALL_SCOPES) { + expect(allScopesInGroups).toContain(scope) + } + }) + + it('FE-OAUTH-SCOPES-008: each item has scope, label, description, group', () => { + const groups = getScopesByGroup(identity) + for (const items of Object.values(groups)) { + for (const item of items) { + expect(item.scope).toBeTruthy() + expect(item.label).toBeTruthy() + expect(item.description).toBeTruthy() + expect(item.group).toBeTruthy() + } + } + }) + + it('FE-OAUTH-SCOPES-009: trips group contains trips:read and trips:write', () => { + const groups = getScopesByGroup(identity) + const tripsGroup = groups['oauth.scope.group.trips'] + expect(tripsGroup).toBeDefined() + const scopeNames = tripsGroup.map(s => s.scope) + expect(scopeNames).toContain('trips:read') + expect(scopeNames).toContain('trips:write') + }) + + it('FE-OAUTH-SCOPES-010: uses translated group name as key', () => { + const t = (key: string) => key === 'oauth.scope.group.trips' ? 'Trips' : key + const groups = getScopesByGroup(t) + expect(groups['Trips']).toBeDefined() + expect(groups['oauth.scope.group.trips']).toBeUndefined() + }) +}) diff --git a/client/src/api/oauthScopes.ts b/client/src/api/oauthScopes.ts new file mode 100644 index 00000000..55cc3c09 --- /dev/null +++ b/client/src/api/oauthScopes.ts @@ -0,0 +1,56 @@ +// Human-readable scope definitions for the OAuth consent page. +// Must stay in sync with server/src/mcp/scopes.ts + +export interface ScopeInfo { + label: string + description: string + group: string +} + +export interface ScopeKeys { + labelKey: string + descriptionKey: string + groupKey: string +} + +export const SCOPE_GROUPS: Record = { + 'trips:read': { labelKey: 'oauth.scope.trips:read.label', descriptionKey: 'oauth.scope.trips:read.description', groupKey: 'oauth.scope.group.trips' }, + 'trips:write': { labelKey: 'oauth.scope.trips:write.label', descriptionKey: 'oauth.scope.trips:write.description', groupKey: 'oauth.scope.group.trips' }, + 'trips:delete': { labelKey: 'oauth.scope.trips:delete.label', descriptionKey: 'oauth.scope.trips:delete.description', groupKey: 'oauth.scope.group.trips' }, + 'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' }, + 'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' }, + 'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' }, + 'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' }, + 'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' }, + 'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' }, + 'packing:write': { labelKey: 'oauth.scope.packing:write.label', descriptionKey: 'oauth.scope.packing:write.description', groupKey: 'oauth.scope.group.packing' }, + 'todos:read': { labelKey: 'oauth.scope.todos:read.label', descriptionKey: 'oauth.scope.todos:read.description', groupKey: 'oauth.scope.group.todos' }, + 'todos:write': { labelKey: 'oauth.scope.todos:write.label', descriptionKey: 'oauth.scope.todos:write.description', groupKey: 'oauth.scope.group.todos' }, + 'budget:read': { labelKey: 'oauth.scope.budget:read.label', descriptionKey: 'oauth.scope.budget:read.description', groupKey: 'oauth.scope.group.budget' }, + 'budget:write': { labelKey: 'oauth.scope.budget:write.label', descriptionKey: 'oauth.scope.budget:write.description', groupKey: 'oauth.scope.group.budget' }, + 'reservations:read': { labelKey: 'oauth.scope.reservations:read.label', descriptionKey: 'oauth.scope.reservations:read.description', groupKey: 'oauth.scope.group.reservations' }, + 'reservations:write': { labelKey: 'oauth.scope.reservations:write.label', descriptionKey: 'oauth.scope.reservations:write.description', groupKey: 'oauth.scope.group.reservations' }, + 'collab:read': { labelKey: 'oauth.scope.collab:read.label', descriptionKey: 'oauth.scope.collab:read.description', groupKey: 'oauth.scope.group.collab' }, + 'collab:write': { labelKey: 'oauth.scope.collab:write.label', descriptionKey: 'oauth.scope.collab:write.description', groupKey: 'oauth.scope.group.collab' }, + 'notifications:read': { labelKey: 'oauth.scope.notifications:read.label', descriptionKey: 'oauth.scope.notifications:read.description', groupKey: 'oauth.scope.group.notifications' }, + 'notifications:write': { labelKey: 'oauth.scope.notifications:write.label', descriptionKey: 'oauth.scope.notifications:write.description', groupKey: 'oauth.scope.group.notifications' }, + 'vacay:read': { labelKey: 'oauth.scope.vacay:read.label', descriptionKey: 'oauth.scope.vacay:read.description', groupKey: 'oauth.scope.group.vacay' }, + 'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' }, + 'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' }, + 'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' }, +} + +export const ALL_SCOPES = Object.keys(SCOPE_GROUPS) + +// Group all scopes for the client registration form +export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))] + +export function getScopesByGroup(t: (key: string) => string): Record> { + const groups: Record> = {} + for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) { + const group = t(keys.groupKey) + if (!groups[group]) groups[group] = [] + groups[group].push({ scope, label: t(keys.labelKey), description: t(keys.descriptionKey), group }) + } + return groups +} diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index 2b4a5207..5a07d357 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -13,6 +13,8 @@ let shouldReconnect = false let refetchCallback: RefetchCallback | null = null let mySocketId: string | null = null let connecting = false +/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */ +let preReconnectHook: (() => Promise) | null = null export function getSocketId(): string | null { return mySocketId @@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void { refetchCallback = fn } +/** + * Register a hook that runs (and is awaited) before the refetch callback + * fires on WS reconnect. Use this to flush the mutation queue so queued + * local writes reach the server before the app reads back canonical state. + * Pass null to clear. + */ +export function setPreReconnectHook(fn: (() => Promise) | null): void { + preReconnectHook = fn +} + function getWsUrl(wsToken: string): string { const protocol = location.protocol === 'https:' ? 'wss' : 'ws' return `${protocol}://${location.host}/ws?token=${wsToken}` @@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise { } }) if (refetchCallback) { - activeTrips.forEach(tripId => { - try { refetchCallback!(tripId) } catch (err: unknown) { - console.error('Failed to refetch trip data on reconnect:', err) - } - }) + const doRefetch = () => { + activeTrips.forEach(tripId => { + try { refetchCallback!(tripId) } catch (err: unknown) { + console.error('Failed to refetch trip data on reconnect:', err) + } + }) + } + // Flush queued mutations first so local writes land before server read-back. + // If the hook fails, still refetch to keep the UI correct. + if (preReconnectHook) { + preReconnectHook().catch(console.error).then(doRefetch) + } else { + doRefetch() + } } } } diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 51054bef..206f063d 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -190,11 +190,12 @@ describe('AddonManager', () => { expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument(); }); - it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown for Memories addon', async () => { + it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => { server.use( http.get('/api/admin/addons', () => HttpResponse.json({ addons: [ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }), buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }), buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }), @@ -204,18 +205,16 @@ describe('AddonManager', () => { ); render(); - // Provider sub-rows are visible + // Provider sub-rows are visible under Journey addon await screen.findByText('Unsplash'); expect(screen.getByText('Pexels')).toBeInTheDocument(); - // Memories row shows name override - expect(screen.getByText('Memories providers')).toBeInTheDocument(); + // Journey addon is rendered + expect(screen.getByText('Journey')).toBeInTheDocument(); - // The photos addon row itself has no top-level toggle (hideToggle = true) - // The toggle buttons are only for the providers + // Toggle buttons: journey toggle + 2 provider toggles const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); - // Should be 2 provider toggles (no main toggle for the photos addon) - expect(toggleBtns.length).toBe(2); + expect(toggleBtns.length).toBe(3); }); it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => { diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index b45f9f00..8a564381 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, } interface Addon { @@ -103,11 +103,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } } } - const tripAddons = addons.filter(a => a.type === 'trip') - const globalAddons = addons.filter(a => a.type === 'global') const photoProviderAddons = addons.filter(isPhotoProviderAddon) + const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon) + const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) + const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') - const photosAddon = tripAddons.find(isPhotosAddon) const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ key: provider.id, label: provider.name, @@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking } {tripAddons.map(addon => (
- - {photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && ( -
-
- {providerOptions.map(provider => ( -
-
-
{provider.label}
-
{provider.description}
-
-
- - {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} - - -
-
- ))} -
-
- )} + {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{globalAddons.map(addon => ( - +
+ + {/* Memories providers as sub-items under Journey addon */} + {addon.id === 'journey' && providerOptions.length > 0 && ( +
+
+ {providerOptions.map(provider => ( +
+
+
{provider.label}
+
{provider.description}
+
+
+ + {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + +
+
+ ))} +
+
+ )} +
))}
)} diff --git a/client/src/components/Admin/AdminMcpTokensPanel.test.tsx b/client/src/components/Admin/AdminMcpTokensPanel.test.tsx index 3a5be8f7..8abcd44d 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.test.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-010 +// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016 import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -197,4 +197,127 @@ describe('AdminMcpTokensPanel', () => { render(<>); await screen.findByText('Failed to load tokens'); }); + + it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => { + server.use( + http.get('/api/admin/oauth-sessions', async () => { + await new Promise(resolve => setTimeout(resolve, 200)); + return HttpResponse.json({ sessions: [] }); + }) + ); + render(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => { + server.use( + http.get('/api/admin/oauth-sessions', () => + HttpResponse.json({ sessions: [] }) + ) + ); + render(); + await screen.findByText('No active OAuth sessions'); + }); + + it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => { + server.use( + http.get('/api/admin/oauth-sessions', () => + HttpResponse.json({ + sessions: [ + { + id: 1, + client_name: 'Claude Desktop', + username: 'alice', + scopes: ['trips:read', 'budget:read'], + created_at: '2025-01-01T00:00:00Z', + }, + ], + }) + ) + ); + render(); + await screen.findByText('Claude Desktop'); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('trips:read')).toBeInTheDocument(); + }); + + it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => { + const user = userEvent.setup(); + // 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears + const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read']; + server.use( + http.get('/api/admin/oauth-sessions', () => + HttpResponse.json({ + sessions: [ + { id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' }, + ], + }) + ) + ); + render(); + await screen.findByText('App'); + // "+1 more" button should appear + const moreBtn = await screen.findByText(/\+1 more/); + expect(moreBtn).toBeInTheDocument(); + await user.click(moreBtn); + // After expand, "show less" appears + expect(await screen.findByText('show less')).toBeInTheDocument(); + }); + + it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/admin/oauth-sessions', () => + HttpResponse.json({ + sessions: [ + { id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' }, + ], + }) + ), + http.delete('/api/admin/oauth-sessions/5', () => + HttpResponse.json({ success: true }) + ) + ); + render(<>); + await screen.findByText('Revoke Me'); + + // Click the revoke (trash) button next to the session + const deleteBtn = screen.getAllByTitle('Delete')[0]; + await user.click(deleteBtn); + + // Confirmation modal opens + expect(screen.getByText('Revoke Session')).toBeInTheDocument(); + // Confirm — find the modal's Delete button (has no title, unlike the trash icon) + const deleteBtns = screen.getAllByRole('button', { name: 'Delete' }); + const confirmBtn = deleteBtns.find(b => !b.title); + await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]); + await waitFor(() => { + expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument(); + }); + }); + + it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/admin/oauth-sessions', () => + HttpResponse.json({ + sessions: [ + { id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' }, + ], + }) + ), + http.delete('/api/admin/oauth-sessions/6', () => + HttpResponse.json({ error: 'forbidden' }, { status: 403 }) + ) + ); + render(<>); + await screen.findByText('Error Session'); + + const deleteBtn = screen.getAllByTitle('Delete')[0]; + await user.click(deleteBtn); + const deleteBtns = screen.getAllByRole('button', { name: 'Delete' }); + const confirmBtn = deleteBtns.find(b => !b.title); + await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]); + await screen.findByText('Failed to revoke session'); + }); }); diff --git a/client/src/components/Admin/AdminMcpTokensPanel.tsx b/client/src/components/Admin/AdminMcpTokensPanel.tsx index 8a89f92d..7173ae9c 100644 --- a/client/src/components/Admin/AdminMcpTokensPanel.tsx +++ b/client/src/components/Admin/AdminMcpTokensPanel.tsx @@ -1,9 +1,21 @@ import { useState, useEffect } from 'react' import { adminApi } from '../../api/client' import { useToast } from '../shared/Toast' -import { Key, Trash2, User, Loader2 } from 'lucide-react' +import { Key, Trash2, User, Loader2, Shield } from 'lucide-react' import { useTranslation } from '../../i18n' +interface AdminOAuthSession { + id: number + client_id: string + client_name: string + user_id: number + username: string + scopes: string[] + access_token_expires_at: string + refresh_token_expires_at: string + created_at: string +} + interface AdminMcpToken { id: number name: string @@ -14,21 +26,49 @@ interface AdminMcpToken { username: string } +const SCOPES_PREVIEW = 6 + export default function AdminMcpTokensPanel() { + const [sessions, setSessions] = useState([]) + const [sessionsLoading, setSessionsLoading] = useState(true) const [tokens, setTokens] = useState([]) - const [isLoading, setIsLoading] = useState(true) + const [tokensLoading, setTokensLoading] = useState(true) + const [expandedScopes, setExpandedScopes] = useState>(new Set()) + const [revokeConfirmId, setRevokeConfirmId] = useState(null) const [deleteConfirmId, setDeleteConfirmId] = useState(null) + + const toggleScopes = (id: number) => + setExpandedScopes(prev => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) const toast = useToast() const { t, locale } = useTranslation() useEffect(() => { - setIsLoading(true) + adminApi.oauthSessions() + .then(d => setSessions(d.sessions || [])) + .catch(() => toast.error(t('admin.oauthSessions.loadError'))) + .finally(() => setSessionsLoading(false)) + adminApi.mcpTokens() .then(d => setTokens(d.tokens || [])) .catch(() => toast.error(t('admin.mcpTokens.loadError'))) - .finally(() => setIsLoading(false)) + .finally(() => setTokensLoading(false)) }, []) + const handleRevoke = async (id: number) => { + try { + await adminApi.revokeOAuthSession(id) + setSessions(prev => prev.filter(s => s.id !== id)) + setRevokeConfirmId(null) + toast.success(t('admin.oauthSessions.revokeSuccess')) + } catch { + toast.error(t('admin.oauthSessions.revokeError')) + } + } + const handleDelete = async (id: number) => { try { await adminApi.deleteMcpToken(id) @@ -47,55 +87,156 @@ export default function AdminMcpTokensPanel() {

{t('admin.mcpTokens.subtitle')}

-
- {isLoading ? ( -
- -
- ) : tokens.length === 0 ? ( -
- -

{t('admin.mcpTokens.empty')}

-
- ) : ( - <> -
- {t('admin.mcpTokens.tokenName')} - {t('admin.mcpTokens.owner')} - {t('admin.mcpTokens.created')} - {t('admin.mcpTokens.lastUsed')} - + {/* OAuth Sessions */} +
+

{t('admin.oauthSessions.sectionTitle')}

+
+ {sessionsLoading ? ( +
+
- {tokens.map((token, i) => ( -
-
-

{token.name}

-

{token.token_prefix}...

-
-
- - {token.username} -
- - {new Date(token.created_at).toLocaleDateString(locale)} - - - {token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')} - - + ) : sessions.length === 0 ? ( +
+ +

{t('admin.oauthSessions.empty')}

+
+ ) : ( + <> +
+ {t('admin.oauthSessions.clientName')} + {t('admin.oauthSessions.owner')} + {t('admin.oauthSessions.created')} +
- ))} - - )} + {sessions.map((session, i) => { + const expanded = expandedScopes.has(session.id) + const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW) + const hidden = session.scopes.length - SCOPES_PREVIEW + return ( +
+
+

{session.client_name}

+
+ {visible.map(scope => ( + + {scope} + + ))} + {!expanded && hidden > 0 && ( + + )} + {expanded && hidden > 0 && ( + + )} +
+
+
+ + {session.username} +
+ + {new Date(session.created_at).toLocaleDateString(locale)} + + +
+ ) + })} + + )} +
+ {/* MCP Tokens */} +
+

{t('admin.mcpTokens.sectionTitle')}

+
+ {tokensLoading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +
+ +

{t('admin.mcpTokens.empty')}

+
+ ) : ( + <> +
+ {t('admin.mcpTokens.tokenName')} + {t('admin.mcpTokens.owner')} + {t('admin.mcpTokens.created')} + {t('admin.mcpTokens.lastUsed')} + +
+ {tokens.map((token, i) => ( +
+
+

{token.name}

+

{token.token_prefix}...

+
+
+ + {token.username} +
+ + {new Date(token.created_at).toLocaleDateString(locale)} + + + {token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')} + + +
+ ))} + + )} +
+
+ + {/* Revoke OAuth session modal */} + {revokeConfirmId !== null && ( +
{ if (e.target === e.currentTarget) setRevokeConfirmId(null) }}> +
+

{t('admin.oauthSessions.revokeTitle')}

+

{t('admin.oauthSessions.revokeMessage')}

+
+ + +
+
+
+ )} + + {/* Delete MCP token modal */} {deleteConfirmId !== null && (
{ if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> diff --git a/client/src/components/Admin/GitHubPanel.test.tsx b/client/src/components/Admin/GitHubPanel.test.tsx index edf45fdf..617bdd88 100644 --- a/client/src/components/Admin/GitHubPanel.test.tsx +++ b/client/src/components/Admin/GitHubPanel.test.tsx @@ -133,7 +133,7 @@ describe('GitHubPanel', () => { server.use( http.get('/api/admin/github-releases', () => HttpResponse.json([r])), ); - render(); + render(); await screen.findByText('v3.0.0-beta.1'); expect(screen.getByText('Pre-release')).toBeInTheDocument(); }); diff --git a/client/src/components/Admin/GitHubPanel.tsx b/client/src/components/Admin/GitHubPanel.tsx index ad76c2a0..02008da2 100644 --- a/client/src/components/Admin/GitHubPanel.tsx +++ b/client/src/components/Admin/GitHubPanel.tsx @@ -6,12 +6,18 @@ import apiClient from '../../api/client' const REPO = 'mauriceboe/TREK' const PER_PAGE = 10 -export default function GitHubPanel() { +interface GithubRelease { + id: number + prerelease: boolean + [key: string]: unknown +} + +export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) { const { t, language } = useTranslation() - const [releases, setReleases] = useState([]) + const [releases, setReleases] = useState([]) const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [expanded, setExpanded] = useState({}) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState>({}) const [page, setPage] = useState(1) const [hasMore, setHasMore] = useState(true) const [loadingMore, setLoadingMore] = useState(false) @@ -273,7 +279,7 @@ export default function GitHubPanel() {
- {releases.map((release, idx) => { + {(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => { const isLatest = idx === 0 const isExpanded = expanded[release.id] diff --git a/client/src/components/Budget/BudgetPanel.test.tsx b/client/src/components/Budget/BudgetPanel.test.tsx index 4a48d9ba..c912d651 100644 --- a/client/src/components/Budget/BudgetPanel.test.tsx +++ b/client/src/components/Budget/BudgetPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-020 +// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040 import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useSettingsStore } from '../../store/settingsStore'; +import { usePermissionsStore } from '../../store/permissionsStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'; import BudgetPanel from './BudgetPanel'; @@ -418,4 +419,80 @@ describe('BudgetPanel', () => { // Grand total card shows 300.00 expect(screen.getByText('300.00')).toBeInTheDocument(); }); + + it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => { + // Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1) + seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); + // Use a user with id != 1 so they're not the owner + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) }); + const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 }; + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) + ); + render(); + await screen.findByText('Read Only Item'); + // In read-only mode the Delete button should not be visible + expect(screen.queryByTitle('Delete')).not.toBeInTheDocument(); + }); + + it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => { + seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) }); + const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' }; + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) + ); + render(); + await screen.findByText('Train'); + // expense_date is rendered as plain text in read-only mode + await screen.findByText('2025-06-15'); + }); + + it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => { + const user = userEvent.setup(); + const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 }; + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })), + http.get('/api/trips/1/budget/settlement', () => + HttpResponse.json({ + balances: [ + { user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: 30 }, + ], + flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }] + }) + ), + http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })), + ); + const tripMembers = [ + { id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, + { id: 2, username: 'bob', avatar_url: null }, + ]; + render(); + await screen.findByText('Lunch'); + // Trigger settlement display + const settlementBtn = await screen.findByRole('button', { name: /settlement/i }); + await user.click(settlementBtn); + await screen.findByText('alice'); + // Avatar image should be rendered for alice + const avatarImg = screen.getAllByRole('img'); + expect(avatarImg.length).toBeGreaterThan(0); + }); + + it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => { + seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } }); + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) }); + const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null }; + server.use( + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })) + ); + render(); + await screen.findByText('Snack'); + // When expense_date is null, the fallback '—' is shown + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThan(0); + }); }); diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 47011eaf..a41cfb6a 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -956,15 +956,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro -
- {pieSegments.map(seg => { +
+ {pieSegments.map((seg, i) => { const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' return ( -
-
- {seg.name} - {fmt(seg.value, currency)} - {pct}% +
0 ? '1px solid var(--border-secondary)' : 'none' }}> +
+
+ {seg.name} +
+
+ {fmt(seg.value, currency)} + {pct}% +
) })} diff --git a/client/src/components/Collab/CollabChat.test.tsx b/client/src/components/Collab/CollabChat.test.tsx index fdfd6dee..072cbd62 100644 --- a/client/src/components/Collab/CollabChat.test.tsx +++ b/client/src/components/Collab/CollabChat.test.tsx @@ -10,6 +10,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index 251a4439..bba42f4c 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -370,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { const [showEmoji, setShowEmoji] = useState(false) const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y } const [deletingIds, setDeletingIds] = useState(new Set()) + const deleteTimersRef = useRef[]>([]) + + useEffect(() => { + return () => { deleteTimersRef.current.forEach(clearTimeout) } + }, []) const containerRef = useRef(null) const messagesRef = useRef(messages) @@ -483,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { requestAnimationFrame(() => { setDeletingIds(prev => new Set(prev).add(msgId)) }) - setTimeout(async () => { + const t = setTimeout(async () => { try { await collabApi.deleteMessage(tripId, msgId) setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m)) } catch {} setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s }) }, 400) + deleteTimersRef.current.push(t) }, [tripId]) const handleReact = useCallback(async (msgId, emoji) => { @@ -762,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} {/* Composer */} -
+
{/* Reply preview */} {replyTo && (
({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Collab/CollabNotes.tsx b/client/src/components/Collab/CollabNotes.tsx index 66a4dbd7..2d6f253c 100644 --- a/client/src/components/Collab/CollabNotes.tsx +++ b/client/src/components/Collab/CollabNotes.tsx @@ -3,9 +3,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import DOM from 'react-dom' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react' import { collabApi } from '../../api/client' import { getAuthUrl } from '../../api/authUrl' +import { openFile } from '../../utils/fileDownload' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { addListener, removeListener } from '../../api/websocket' @@ -110,10 +112,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) { const isPdf = file.mime_type === 'application/pdf' const isTxt = file.mime_type?.startsWith('text/') - const openInNewTab = async () => { - const u = await getAuthUrl(rawUrl, 'download') - window.open(u, '_blank', 'noreferrer') - } + const openInNewTab = () => openFile(rawUrl).catch(() => {}) return ReactDOM.createPortal(
@@ -845,7 +844,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi maxHeight: '4.5em', overflow: 'hidden', wordBreak: 'break-word', fontFamily: FONT, }}> - {note.content} + {note.content}
)}
@@ -1352,7 +1351,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
- {viewingNote.content || ''} + {viewingNote.content || ''} {(viewingNote.attachments || []).length > 0 && (
{t('files.title')}
diff --git a/client/src/components/Collab/CollabPanel.test.tsx b/client/src/components/Collab/CollabPanel.test.tsx index 23baa81d..d13217b0 100644 --- a/client/src/components/Collab/CollabPanel.test.tsx +++ b/client/src/components/Collab/CollabPanel.test.tsx @@ -13,6 +13,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })) diff --git a/client/src/components/Collab/CollabPolls.test.tsx b/client/src/components/Collab/CollabPolls.test.tsx index 150ac2ac..2fef0d88 100644 --- a/client/src/components/Collab/CollabPolls.test.tsx +++ b/client/src/components/Collab/CollabPolls.test.tsx @@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({ disconnect: vi.fn(), getSocketId: vi.fn(() => null), setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), })); diff --git a/client/src/components/Collab/WhatsNextWidget.tsx b/client/src/components/Collab/WhatsNextWidget.tsx index c5fd11a2..90d39caf 100644 --- a/client/src/components/Collab/WhatsNextWidget.tsx +++ b/client/src/components/Collab/WhatsNextWidget.tsx @@ -16,12 +16,13 @@ function formatTime(timeStr, is12h) { } function formatDayLabel(date, t, locale) { - const d = new Date(date + 'T00:00:00') const now = new Date() - const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1) + const nowDate = now.toISOString().split('T')[0] + const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)) + const tomorrowDate = tomorrowUtc.toISOString().split('T')[0] - if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today' - if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow' + if (date === nowDate) return t('collab.whatsNext.today') || 'Today' + if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow' return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 4295c46a..6092806a 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -10,6 +10,7 @@ import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { getAuthUrl } from '../../api/authUrl' +import { downloadFile, openFile } from '../../utils/fileDownload' function isImage(mimeType) { if (!mimeType) return false @@ -30,16 +31,8 @@ function formatSize(bytes) { return `${(bytes / 1024 / 1024).toFixed(1)} MB` } -async function triggerDownload(url: string, filename: string) { - const authUrl = await getAuthUrl(url, 'download') - const res = await fetch(authUrl) - const blob = await res.blob() - const a = document.createElement('a') - a.href = URL.createObjectURL(blob) - a.download = filename - document.body.appendChild(a) - a.click() - setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 100) +function triggerDownload(url: string, filename: string) { + downloadFile(url, filename).catch(() => {}) } function formatDateWithLocale(dateStr, locale) { @@ -120,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
+

diff --git a/client/src/components/Journey/JournalBody.test.tsx b/client/src/components/Journey/JournalBody.test.tsx new file mode 100644 index 00000000..4a74878d --- /dev/null +++ b/client/src/components/Journey/JournalBody.test.tsx @@ -0,0 +1,39 @@ +// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005 + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '../../../tests/helpers/render'; +import JournalBody from './JournalBody'; + +describe('JournalBody', () => { + it('FE-COMP-JOURNALBODY-001: renders plain text content', () => { + render(); + expect(screen.getByText('Hello traveller')).toBeInTheDocument(); + }); + + it('FE-COMP-JOURNALBODY-002: renders bold markdown as ', () => { + const { container } = render(); + const strong = container.querySelector('strong'); + expect(strong).toBeInTheDocument(); + expect(strong!.textContent).toBe('bold'); + }); + + it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => { + render(); + const link = screen.getByRole('link', { name: 'Visit' }); + expect(link).toHaveAttribute('href', 'https://example.com'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => { + const { container } = render(); + const p = container.querySelector('p'); + expect(p).toBeInTheDocument(); + expect(p!.textContent).toBe('Section Title'); + }); + + it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => { + const { container } = render(); + expect(container.querySelector('.journal-body')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Journey/JournalBody.tsx b/client/src/components/Journey/JournalBody.tsx new file mode 100644 index 00000000..2caa84c3 --- /dev/null +++ b/client/src/components/Journey/JournalBody.tsx @@ -0,0 +1,70 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' + +interface Props { + text: string + dark?: boolean +} + +export default function JournalBody({ text, dark }: Props) { + return ( +
+

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + p: ({ children }) =>

{children}

, + blockquote: ({ children }) => ( +
{children}
+ ), + a: ({ href, children }) => ( + + {children} + + ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + hr: () =>
    , + code: ({ children, className }) => { + const isBlock = className?.includes('language-') + if (isBlock) { + return ( +
    +                  {children}
    +                
    + ) + } + return ( + {children} + ) + }, + }} + > + {text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')} +
    +
    + ) +} diff --git a/client/src/components/Journey/JourneyMap.test.tsx b/client/src/components/Journey/JourneyMap.test.tsx new file mode 100644 index 00000000..4eff50a8 --- /dev/null +++ b/client/src/components/Journey/JourneyMap.test.tsx @@ -0,0 +1,230 @@ +// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-006 + +vi.mock('../../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), +})); + +// Leaflet does not work in jsdom — mock the entire library +vi.mock('leaflet', () => { + const mockMarker = { + addTo: vi.fn().mockReturnThis(), + bindTooltip: vi.fn().mockReturnThis(), + on: vi.fn().mockReturnThis(), + setIcon: vi.fn(), + setZIndexOffset: vi.fn(), + getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })), + }; + const mockMap = { + remove: vi.fn(), + invalidateSize: vi.fn(), + fitBounds: vi.fn(), + setView: vi.fn(), + flyTo: vi.fn(), + getZoom: vi.fn(() => 10), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; + return { + default: { + map: vi.fn(() => mockMap), + tileLayer: vi.fn(() => ({ addTo: vi.fn() })), + marker: vi.fn(() => mockMarker), + polyline: vi.fn(() => ({ addTo: vi.fn() })), + divIcon: vi.fn(() => ({})), + latLngBounds: vi.fn(() => ({})), + }, + map: vi.fn(() => mockMap), + tileLayer: vi.fn(() => ({ addTo: vi.fn() })), + marker: vi.fn(() => mockMarker), + polyline: vi.fn(() => ({ addTo: vi.fn() })), + divIcon: vi.fn(() => ({})), + latLngBounds: vi.fn(() => ({})), + }; +}); + +import React from 'react'; +import { render } from '../../../tests/helpers/render'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useSettingsStore } from '../../store/settingsStore'; +import { buildSettings } from '../../../tests/helpers/factories'; +import L from 'leaflet'; +import JourneyMap from './JourneyMap'; +import type { JourneyMapHandle } from './JourneyMap'; + +const entriesWithCoords = [ + { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' }, + { id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' }, +]; + +const entriesWithoutCoords = [ + { id: 'e3', lat: 0, lng: 0, title: 'Unknown Place', mood: null, entry_date: '2025-06-03' }, +]; + +const mixedEntries = [ + ...entriesWithCoords, + ...entriesWithoutCoords, +]; + +beforeEach(() => { + resetAllStores(); + seedStore(useSettingsStore, { settings: buildSettings() }); + vi.clearAllMocks(); +}); + +describe('JourneyMap', () => { + it('FE-COMP-JOURNEYMAP-001: renders map container', () => { + const { container } = render( + + ); + // The component renders a div with a child div ref for the Leaflet map + expect(container.firstChild).toBeInTheDocument(); + expect(L.map).toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-002: renders markers for entries with coordinates', () => { + render( + + ); + // Two entries with valid lat/lng should produce two markers + expect(L.marker).toHaveBeenCalledTimes(2); + }); + + it('FE-COMP-JOURNEYMAP-003: does not render markers for entries without coordinates', () => { + render( + + ); + // Entry with lat=0 and lng=0 is filtered out by buildMarkerItems (if (e.lat && e.lng)) + expect(L.marker).not.toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-004: renders polyline connecting entries', () => { + render( + + ); + // With 2+ marker items, a route polyline is drawn + expect(L.polyline).toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-005: shows entry title in marker tooltip', () => { + render( + + ); + // Each marker calls bindTooltip with the entry label + const mockMarkerInstance = (L.marker as any).mock.results[0].value; + expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith( + 'Paris', + expect.objectContaining({ direction: 'top' }), + ); + }); + + it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => { + const ref = React.createRef(); + render( + + ); + expect(ref.current).not.toBeNull(); + expect(typeof ref.current!.focusMarker).toBe('function'); + expect(typeof ref.current!.highlightMarker).toBe('function'); + }); + + it('FE-COMP-JOURNEYMAP-007: renders SVG pin markers via divIcon', () => { + render( + + ); + // Each marker is created with L.divIcon containing SVG html + expect(L.divIcon).toHaveBeenCalledTimes(2); + const firstCall = (L.divIcon as any).mock.calls[0][0]; + expect(firstCall.html).toContain(''); + // Marker index label "1" for first entry + expect(firstCall.html).toContain('>1<'); + }); + + it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => { + const entriesWithMood = [ + { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' }, + { id: 'e2', lat: 52.52, lng: 13.405, title: 'Sad Berlin', mood: 'sad', entry_date: '2025-06-02' }, + ]; + render( + + ); + // Markers are still created (mood does not prevent rendering) + expect(L.marker).toHaveBeenCalledTimes(2); + // Tooltips use the entry titles + const mockMarker1 = (L.marker as any).mock.results[0].value; + expect(mockMarker1.bindTooltip).toHaveBeenCalledWith( + 'Happy Paris', + expect.objectContaining({ direction: 'top' }), + ); + const mockMarker2 = (L.marker as any).mock.results[1].value; + expect(mockMarker2.bindTooltip).toHaveBeenCalledWith( + 'Sad Berlin', + expect.objectContaining({ direction: 'top' }), + ); + }); + + it('FE-COMP-JOURNEYMAP-009: draws route polyline connecting multiple markers', () => { + const threeEntries = [ + { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' }, + { id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' }, + { id: 'e3', lat: 41.9028, lng: 12.4964, title: 'Rome', mood: null, entry_date: '2025-06-03' }, + ]; + render( + + ); + // Route polyline is drawn for items.length > 1 + expect(L.polyline).toHaveBeenCalled(); + const polylineCall = (L.polyline as any).mock.calls[0]; + // Should contain coordinates for all three entries + expect(polylineCall[0].length).toBe(3); + // Verify dashed style + expect(polylineCall[1]).toMatchObject({ dashArray: '4 6' }); + }); + + it('FE-COMP-JOURNEYMAP-010: fitBounds is called for auto-zoom', () => { + // Trigger requestAnimationFrame synchronously + const origRAF = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; }; + + render( + + ); + + const mockMap = (L.map as any).mock.results[0].value; + // fitBounds is called inside requestAnimationFrame with the collected coordinates + expect(mockMap.fitBounds).toHaveBeenCalled(); + expect(L.latLngBounds).toHaveBeenCalled(); + + globalThis.requestAnimationFrame = origRAF; + }); + + it('FE-COMP-JOURNEYMAP-011: single entry creates marker but no polyline', () => { + const singleEntry = [ + { id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Solo Paris', mood: null, entry_date: '2025-06-01' }, + ]; + render( + + ); + // One marker created + expect(L.marker).toHaveBeenCalledTimes(1); + // No route polyline — polyline is only drawn when items.length > 1 + expect(L.polyline).not.toHaveBeenCalled(); + }); + + it('FE-COMP-JOURNEYMAP-012: renders zoom control buttons', () => { + const { container } = render( + + ); + // The component renders zoom in (+) and zoom out (−) buttons + const buttons = container.querySelectorAll('button'); + expect(buttons.length).toBe(2); + expect(buttons[0].textContent).toBe('+'); + expect(buttons[1].textContent).toBe('−'); + }); +}); diff --git a/client/src/components/Journey/JourneyMap.tsx b/client/src/components/Journey/JourneyMap.tsx new file mode 100644 index 00000000..88b08d0b --- /dev/null +++ b/client/src/components/Journey/JourneyMap.tsx @@ -0,0 +1,303 @@ +import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react' +import L from 'leaflet' +import { useSettingsStore } from '../../store/settingsStore' + +export interface MapMarkerItem { + id: string + lat: number + lng: number + label: string + mood?: string | null + time: string +} + +export interface JourneyMapHandle { + highlightMarker: (id: string | null) => void + focusMarker: (id: string) => void +} + +interface MapEntry { + id: string + lat: number + lng: number + title?: string | null + mood?: string | null + entry_date: string +} + +interface Props { + checkins: any[] + entries: MapEntry[] + trail?: { lat: number; lng: number }[] + height?: number + dark?: boolean + activeMarkerId?: string | null + onMarkerClick?: (id: string, type?: string) => void +} + +function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] { + const items: MapMarkerItem[] = [] + for (const e of entries) { + if (e.lat && e.lng) { + items.push({ + id: e.id, + lat: e.lat, + lng: e.lng, + label: e.title || 'Entry', + mood: e.mood, + time: e.entry_date, + }) + } + } + items.sort((a, b) => a.time.localeCompare(b.time)) + return items +} + +const MARKER_W = 28 +const MARKER_H = 36 + +function markerSvg(index: number, highlighted: boolean, dark: boolean): string { + const fill = dark + ? (highlighted ? '#FAFAFA' : '#FAFAFA') + : (highlighted ? '#18181B' : '#18181B') + const textColor = dark + ? (highlighted ? '#18181B' : '#18181B') + : (highlighted ? '#fff' : '#fff') + const stroke = dark ? '#3F3F46' : '#fff' + const shadow = highlighted + ? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))' + : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' + const label = String(index + 1) + const scale = highlighted ? 1.2 : 1 + + return `
    + + + + ${label} + +
    ` +} + +const EMPTY_TRAIL: { lat: number; lng: number }[] = [] + +const JourneyMap = forwardRef(function JourneyMap( + { entries, trail, height = 220, dark, activeMarkerId, onMarkerClick }, + ref +) { + const stableTrail = trail || EMPTY_TRAIL + const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url) + const containerRef = useRef(null) + const mapRef = useRef(null) + const markersRef = useRef>(new Map()) + const itemsRef = useRef([]) + const highlightedRef = useRef(null) + const onMarkerClickRef = useRef(onMarkerClick) + onMarkerClickRef.current = onMarkerClick + + const darkRef = useRef(dark) + darkRef.current = dark + + const highlightMarker = useCallback((id: string | null) => { + const prev = highlightedRef.current + highlightedRef.current = id + const isDark = !!darkRef.current + + if (prev && prev !== id) { + const marker = markersRef.current.get(prev) + const item = itemsRef.current.find(i => i.id === prev) + if (marker && item) { + const idx = itemsRef.current.indexOf(item) + marker.setIcon(L.divIcon({ + className: '', + iconSize: [MARKER_W, MARKER_H], + iconAnchor: [MARKER_W / 2, MARKER_H], + html: markerSvg(idx, false, isDark), + })) + marker.setZIndexOffset(0) + } + } + + if (id) { + const marker = markersRef.current.get(id) + const item = itemsRef.current.find(i => i.id === id) + if (marker && item) { + const idx = itemsRef.current.indexOf(item) + marker.setIcon(L.divIcon({ + className: '', + iconSize: [MARKER_W, MARKER_H], + iconAnchor: [MARKER_W / 2, MARKER_H], + html: markerSvg(idx, true, isDark), + })) + marker.setZIndexOffset(1000) + } + } + }, []) + + const focusMarker = useCallback((id: string) => { + highlightMarker(id) + const marker = markersRef.current.get(id) + if (marker && mapRef.current) { + mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 }) + } + }, []) + + useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), []) + + useEffect(() => { + if (!containerRef.current) return + + if (mapRef.current) { + mapRef.current.remove() + mapRef.current = null + } + markersRef.current.clear() + + const map = L.map(containerRef.current, { + zoomControl: false, + attributionControl: true, + scrollWheelZoom: false, + dragging: true, + touchZoom: true, + }) + mapRef.current = map + + const defaultTile = dark + ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' + : 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png' + L.tileLayer(mapTileUrl || defaultTile, { + maxZoom: 18, + attribution: '© OpenStreetMap', + referrerPolicy: 'strict-origin-when-cross-origin', + } as any).addTo(map) + + const items = buildMarkerItems(entries) + itemsRef.current = items + + const allCoords: L.LatLngTuple[] = [] + + if (stableTrail.length > 1) { + const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple) + L.polyline(coords, { + color: '#6366f1', weight: 3, opacity: 0.4, + dashArray: '6 4', lineCap: 'round', + }).addTo(map) + coords.forEach(c => allCoords.push(c)) + } + + // route polyline — subtle dashed connection + if (items.length > 1) { + const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple) + L.polyline(routeCoords, { + color: dark ? '#71717A' : '#A1A1AA', + weight: 1.5, + opacity: 0.5, + dashArray: '4 6', + lineCap: 'round', lineJoin: 'round', + }).addTo(map) + } + + // place markers + items.forEach((item, i) => { + const pos: L.LatLngTuple = [item.lat, item.lng] + allCoords.push(pos) + + const icon = L.divIcon({ + className: '', + iconSize: [MARKER_W, MARKER_H], + iconAnchor: [MARKER_W / 2, MARKER_H], + html: markerSvg(i, false, !!dark), + }) + + const marker = L.marker(pos, { icon }).addTo(map) + marker.bindTooltip(item.label, { + direction: 'top', + offset: [0, -MARKER_H], + className: 'map-tooltip', + }) + + marker.on('click', () => { + onMarkerClickRef.current?.(item.id) + }) + + markersRef.current.set(item.id, marker) + }) + + // fit bounds + requestAnimationFrame(() => { + if (!mapRef.current) return + try { + map.invalidateSize() + if (allCoords.length > 0) { + map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 }) + } else { + map.setView([30, 0], 2) + } + } catch {} + }) + + setTimeout(() => { + if (mapRef.current) map.invalidateSize() + }, 200) + + return () => { + map.remove() + mapRef.current = null + markersRef.current.clear() + } + }, [entries, stableTrail, dark, mapTileUrl]) + + // react to activeMarkerId prop changes — runs after map is built + useEffect(() => { + if (!activeMarkerId || !mapRef.current) return + // small delay to ensure markers are rendered after map build + const timer = setTimeout(() => { + highlightMarker(activeMarkerId) + const marker = markersRef.current.get(activeMarkerId) + if (marker && mapRef.current) { + mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 }) + } + }, 50) + return () => clearTimeout(timer) + }, [activeMarkerId]) + + const zoomIn = () => mapRef.current?.zoomIn() + const zoomOut = () => mapRef.current?.zoomOut() + + return ( +
    +
    +
    + + +
    +
    + ) +}) + +export default JourneyMap diff --git a/client/src/components/Journey/MarkdownToolbar.test.tsx b/client/src/components/Journey/MarkdownToolbar.test.tsx new file mode 100644 index 00000000..6910dbc8 --- /dev/null +++ b/client/src/components/Journey/MarkdownToolbar.test.tsx @@ -0,0 +1,72 @@ +// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006 + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import MarkdownToolbar from './MarkdownToolbar'; +import React from 'react'; + +function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) { + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.selectionStart = selectionStart; + textarea.selectionEnd = selectionEnd; + textarea.focus = vi.fn(); + textarea.setSelectionRange = vi.fn(); + return { current: textarea } as React.RefObject; +} + +describe('MarkdownToolbar', () => { + let onUpdate: ReturnType; + + beforeEach(() => { + onUpdate = vi.fn(); + }); + + it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => { + const ref = createTextareaRef(); + render(); + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(8); + }); + + it('FE-COMP-MDTOOLBAR-002: buttons have correct title labels', () => { + const ref = createTextareaRef(); + render(); + expect(screen.getByTitle('Bold')).toBeInTheDocument(); + expect(screen.getByTitle('Italic')).toBeInTheDocument(); + expect(screen.getByTitle('Link')).toBeInTheDocument(); + expect(screen.getByTitle('Heading')).toBeInTheDocument(); + expect(screen.getByTitle('Quote')).toBeInTheDocument(); + expect(screen.getByTitle('List')).toBeInTheDocument(); + expect(screen.getByTitle('Ordered')).toBeInTheDocument(); + expect(screen.getByTitle('Divider')).toBeInTheDocument(); + }); + + it('FE-COMP-MDTOOLBAR-003: bold button wraps selected text with **', () => { + const ref = createTextareaRef('hello world', 6, 11); + render(); + fireEvent.click(screen.getByTitle('Bold')); + expect(onUpdate).toHaveBeenCalledWith('hello **world**'); + }); + + it('FE-COMP-MDTOOLBAR-004: italic button wraps selected text with _', () => { + const ref = createTextareaRef('hello world', 6, 11); + render(); + fireEvent.click(screen.getByTitle('Italic')); + expect(onUpdate).toHaveBeenCalledWith('hello _world_'); + }); + + it('FE-COMP-MDTOOLBAR-005: link button wraps selected text as markdown link', () => { + const ref = createTextareaRef('click me', 0, 8); + render(); + fireEvent.click(screen.getByTitle('Link')); + expect(onUpdate).toHaveBeenCalledWith('[click me](url)'); + }); + + it('FE-COMP-MDTOOLBAR-006: heading button inserts line prefix', () => { + const ref = createTextareaRef('my title', 0, 0); + render(); + fireEvent.click(screen.getByTitle('Heading')); + expect(onUpdate).toHaveBeenCalledWith('## my title'); + }); +}); diff --git a/client/src/components/Journey/MarkdownToolbar.tsx b/client/src/components/Journey/MarkdownToolbar.tsx new file mode 100644 index 00000000..4ad519eb --- /dev/null +++ b/client/src/components/Journey/MarkdownToolbar.tsx @@ -0,0 +1,84 @@ +import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react' + +interface Props { + textareaRef: React.RefObject + onUpdate: (value: string) => void + dark?: boolean +} + +type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string } + +const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [ + { icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } }, + { icon: Italic, label: 'Italic', action: { type: 'wrap', before: '_', after: '_' } }, + { icon: Heading2, label: 'Heading', action: { type: 'line', prefix: '## ' } }, + { icon: Quote, label: 'Quote', action: { type: 'line', prefix: '> ' } }, + { icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } }, + { icon: List, label: 'List', action: { type: 'line', prefix: '- ' } }, + { icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } }, + { icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } }, +] + +export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) { + const apply = (action: FormatAction) => { + const ta = textareaRef.current + if (!ta) return + + const start = ta.selectionStart + const end = ta.selectionEnd + const text = ta.value + const selected = text.slice(start, end) + + let result: string + let cursorPos: number + + if (action.type === 'wrap') { + result = text.slice(0, start) + action.before + selected + action.after + text.slice(end) + cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length + } else if (action.type === 'insert') { + result = text.slice(0, start) + action.text + text.slice(end) + cursorPos = start + action.text.length + } else { + // line prefix — find start of current line + const lineStart = text.lastIndexOf('\n', start - 1) + 1 + result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart) + cursorPos = start + action.prefix.length + } + + onUpdate(result) + + // restore cursor after React re-render + requestAnimationFrame(() => { + ta.focus() + ta.setSelectionRange(cursorPos, cursorPos) + }) + } + + return ( +
    + {ACTIONS.map(a => ( + + ))} +
    + ) +} diff --git a/client/src/components/Journey/PhotoLightbox.test.tsx b/client/src/components/Journey/PhotoLightbox.test.tsx new file mode 100644 index 00000000..4d5d8d62 --- /dev/null +++ b/client/src/components/Journey/PhotoLightbox.test.tsx @@ -0,0 +1,98 @@ +// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-008 + +vi.mock('../../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), +})); + +import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import { resetAllStores } from '../../../tests/helpers/store'; +import PhotoLightbox from './PhotoLightbox'; + +const samplePhotos = [ + { id: 'p1', src: '/photos/1.jpg', caption: 'Sunset at the beach' }, + { id: 'p2', src: '/photos/2.jpg', caption: 'Mountain trail' }, + { id: 'p3', src: '/photos/3.jpg', caption: null }, +]; + +beforeEach(() => { + resetAllStores(); +}); + +describe('PhotoLightbox', () => { + it('FE-COMP-LIGHTBOX-001: renders without crashing when open', () => { + const onClose = vi.fn(); + render(); + expect(document.body).toBeInTheDocument(); + }); + + it('FE-COMP-LIGHTBOX-002: shows photo image', () => { + const onClose = vi.fn(); + render(); + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', '/photos/1.jpg'); + }); + + it('FE-COMP-LIGHTBOX-003: shows close button', () => { + const onClose = vi.fn(); + render(); + const buttons = screen.getAllByRole('button'); + // Close button exists (the X button in the top bar) + expect(buttons.length).toBeGreaterThan(0); + }); + + it('FE-COMP-LIGHTBOX-004: previous/next navigation works', () => { + const onClose = vi.fn(); + render(); + // Initially shows photo 1 + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', '/photos/1.jpg'); + + // Navigate to next photo via ArrowRight key + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(screen.getByText('2 / 3')).toBeInTheDocument(); + expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/2.jpg'); + + // Navigate back via ArrowLeft key + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/1.jpg'); + }); + + it('FE-COMP-LIGHTBOX-005: keyboard Escape closes lightbox', () => { + const onClose = vi.fn(); + render(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => { + const onClose = vi.fn(); + render(); + expect(screen.getByText('1 / 3')).toBeInTheDocument(); + }); + + it('FE-COMP-LIGHTBOX-007: does not render when photos array is empty', () => { + const onClose = vi.fn(); + const { container } = render(); + // Component returns null when photo is undefined (empty array, index 0 is undefined) + expect(container.querySelector('img')).not.toBeInTheDocument(); + }); + + it('FE-COMP-LIGHTBOX-008: calls onClose when close button clicked', () => { + const onClose = vi.fn(); + render(); + // The close button is in the top bar — find the button and click it + const buttons = screen.getAllByRole('button'); + // The first button in the top bar is the close (X) button + buttons[0].click(); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Journey/PhotoLightbox.tsx b/client/src/components/Journey/PhotoLightbox.tsx new file mode 100644 index 00000000..e3096ee1 --- /dev/null +++ b/client/src/components/Journey/PhotoLightbox.tsx @@ -0,0 +1,149 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { ChevronLeft, ChevronRight, X } from 'lucide-react' + +interface LightboxPhoto { + id: string + src: string + caption?: string | null + provider?: string + asset_id?: string | null + owner_id?: number | null +} + +interface Props { + photos: LightboxPhoto[] + startIndex?: number + onClose: () => void +} + +export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) { + const [idx, setIdx] = useState(startIndex) + const touchStart = useRef<{ x: number; y: number } | null>(null) + + const photo = photos[idx] + const hasPrev = idx > 0 + const hasNext = idx < photos.length - 1 + + const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev]) + const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + if (e.key === 'ArrowLeft') prev() + if (e.key === 'ArrowRight') next() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [prev, next, onClose]) + + const onTouchStart = (e: React.TouchEvent) => { + const t = e.touches[0] + touchStart.current = { x: t.clientX, y: t.clientY } + } + + const onTouchEnd = (e: React.TouchEvent) => { + if (!touchStart.current) return + const t = e.changedTouches[0] + const dx = t.clientX - touchStart.current.x + const dy = t.clientY - touchStart.current.y + + // swipe down to close + if (dy > 80 && Math.abs(dx) < 60) { + onClose() + return + } + // horizontal swipe + if (Math.abs(dx) > 50 && Math.abs(dy) < 80) { + if (dx < 0) next() + else prev() + } + touchStart.current = null + } + + if (!photo) return null + + return ( +
    + {/* Photo area — centered with nav overlays */} +
    + {/* Top bar */} +
    + + {idx + 1} / {photos.length} + + +
    + + {/* Prev button — visible on hover (desktop), always visible (mobile) */} + {hasPrev && ( + + )} + + {/* Photo */} + {photo.caption + + {/* Next button */} + {hasNext && ( + + )} + + {/* Caption — bottom center overlay */} + {photo.caption && ( +
    +

    {photo.caption}

    +
    + )} +
    +
    + ) +} diff --git a/client/src/components/Journey/moodConfig.test.ts b/client/src/components/Journey/moodConfig.test.ts new file mode 100644 index 00000000..62151a57 --- /dev/null +++ b/client/src/components/Journey/moodConfig.test.ts @@ -0,0 +1,69 @@ +// FE-COMP-MOOD-001 to FE-COMP-MOOD-005 + +import { describe, it, expect } from 'vitest'; +import { MOODS, WEATHERS, getMood, moodColor, tagColors, TAG_STYLES, MOOD_DEFAULT_COLOR } from './moodConfig'; + +describe('moodConfig', () => { + it('FE-COMP-MOOD-001: MOODS contains all five mood definitions', () => { + const ids = MOODS.map(m => m.id); + expect(ids).toEqual(['amazing', 'good', 'neutral', 'tired', 'rough']); + expect(MOODS).toHaveLength(5); + }); + + it('FE-COMP-MOOD-002: every mood has valid hex color and css var', () => { + for (const mood of MOODS) { + expect(mood.color).toMatch(/^#[0-9A-Fa-f]{6}$/); + expect(mood.cssVar).toMatch(/^var\(--mood-.+\)$/); + expect(mood.icon).toBeDefined(); + expect(mood.label).toBeTruthy(); + } + }); + + it('FE-COMP-MOOD-003: getMood returns correct mood or undefined', () => { + expect(getMood('amazing')?.id).toBe('amazing'); + expect(getMood('rough')?.color).toBe('#9B8EC4'); + expect(getMood('nonexistent')).toBeUndefined(); + expect(getMood(null)).toBeUndefined(); + expect(getMood(undefined)).toBeUndefined(); + }); + + it('FE-COMP-MOOD-004: moodColor returns css var or fallback', () => { + expect(moodColor('good')).toBe('var(--mood-good)'); + expect(moodColor(null)).toBe('var(--journal-faint)'); + expect(moodColor('unknown')).toBe('var(--journal-faint)'); + }); + + it('FE-COMP-MOOD-005: WEATHERS contains all eight entries with icons', () => { + expect(WEATHERS).toHaveLength(8); + const ids = WEATHERS.map(w => w.id); + expect(ids).toContain('sunny'); + expect(ids).toContain('snowy'); + expect(ids).toContain('stormy'); + for (const w of WEATHERS) { + expect(w.icon).toBeDefined(); + expect(w.label).toBeTruthy(); + } + }); +}); + +describe('tagColors', () => { + it('FE-COMP-MOOD-006: returns known tag colors for light and dark mode', () => { + const light = tagColors('hidden gem', false); + expect(light.bg).toBe('#dcfce7'); + expect(light.fg).toBe('#166534'); + + const dark = tagColors('hidden gem', true); + expect(dark.bg).toBe('rgba(22,101,52,0.2)'); + expect(dark.fg).toBe('#86efac'); + }); + + it('FE-COMP-MOOD-007: returns fallback colors for unknown tags', () => { + const light = tagColors('random tag', false); + expect(light.bg).toBe('rgba(0,0,0,0.05)'); + expect(light.fg).toBe('#374151'); + + const dark = tagColors('random tag', true); + expect(dark.bg).toBe('rgba(255,255,255,0.07)'); + expect(dark.fg).toBe('#a1a1aa'); + }); +}); diff --git a/client/src/components/Journey/moodConfig.ts b/client/src/components/Journey/moodConfig.ts new file mode 100644 index 00000000..738457db --- /dev/null +++ b/client/src/components/Journey/moodConfig.ts @@ -0,0 +1,65 @@ +import { Sparkles, Sun, Minus, Moon, CloudRain, CloudSun, Cloud, CloudLightning, Snowflake, Thermometer, ThermometerSnowflake } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +export interface MoodDef { + id: string + label: string + icon: LucideIcon + color: string + cssVar: string +} + +export const MOODS: MoodDef[] = [ + { id: 'amazing', label: 'Amazing', icon: Sparkles, color: '#E8654A', cssVar: 'var(--mood-amazing)' }, + { id: 'good', label: 'Good', icon: Sun, color: '#EF9F27', cssVar: 'var(--mood-good)' }, + { id: 'neutral', label: 'Neutral', icon: Minus, color: '#94928C', cssVar: 'var(--mood-neutral)' }, + { id: 'tired', label: 'Tired', icon: Moon, color: '#6B9BD2', cssVar: 'var(--mood-tired)' }, + { id: 'rough', label: 'Rough', icon: CloudRain,color: '#9B8EC4', cssVar: 'var(--mood-rough)' }, +] + +export const MOOD_DEFAULT_COLOR = '#D4D4D4' + +export function getMood(id: string | null | undefined): MoodDef | undefined { + if (!id) return undefined + return MOODS.find(m => m.id === id) +} + +export function moodColor(id: string | null | undefined): string { + return getMood(id)?.cssVar || 'var(--journal-faint)' +} + +export interface WeatherDef { + id: string + label: string + icon: LucideIcon +} + +export const WEATHERS: WeatherDef[] = [ + { id: 'sunny', label: 'Sunny', icon: Sun }, + { id: 'partly', label: 'Partly cloudy', icon: CloudSun }, + { id: 'cloudy', label: 'Cloudy', icon: Cloud }, + { id: 'rainy', label: 'Rainy', icon: CloudRain }, + { id: 'stormy', label: 'Stormy', icon: CloudLightning }, + { id: 'snowy', label: 'Snowy', icon: Snowflake }, + { id: 'hot', label: 'Hot', icon: Thermometer }, + { id: 'cold', label: 'Cold', icon: ThermometerSnowflake }, +] + +export function getWeather(id: string | null | undefined): WeatherDef | undefined { + if (!id) return undefined + return WEATHERS.find(w => w.id === id) +} + +export const TAG_STYLES: Record = { + 'hidden gem': { bg: '#dcfce7', fg: '#166534', darkBg: 'rgba(22,101,52,0.2)', darkFg: '#86efac' }, + 'must revisit': { bg: '#dbeafe', fg: '#1e40af', darkBg: 'rgba(30,64,175,0.2)', darkFg: '#93c5fd' }, + 'best meal': { bg: '#fef3c7', fg: '#92400e', darkBg: 'rgba(146,64,14,0.2)', darkFg: '#fcd34d' }, + 'tourist trap': { bg: '#fee2e2', fg: '#991b1b', darkBg: 'rgba(153,27,27,0.2)', darkFg: '#fca5a5' }, + 'disaster': { bg: '#fce4ec', fg: '#880e4f', darkBg: 'rgba(136,14,79,0.2)', darkFg: '#f48fb1' }, +} + +export function tagColors(tag: string, dark: boolean) { + const known = TAG_STYLES[tag.toLowerCase()] + if (known) return { bg: dark ? known.darkBg : known.bg, fg: dark ? known.darkFg : known.fg } + return { bg: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)', fg: dark ? '#a1a1aa' : '#374151' } +} diff --git a/client/src/components/Journey/stripMarkdown.test.ts b/client/src/components/Journey/stripMarkdown.test.ts new file mode 100644 index 00000000..d932c06d --- /dev/null +++ b/client/src/components/Journey/stripMarkdown.test.ts @@ -0,0 +1,38 @@ +// FE-UTIL-STRIPMD-001 to FE-UTIL-STRIPMD-006 + +import { describe, it, expect } from 'vitest'; +import { stripMarkdown } from './stripMarkdown'; + +describe('stripMarkdown', () => { + it('FE-UTIL-STRIPMD-001: strips bold and italic formatting', () => { + expect(stripMarkdown('**bold** and _italic_')).toBe('bold and italic'); + expect(stripMarkdown('__also bold__ and *also italic*')).toBe('also bold and also italic'); + }); + + it('FE-UTIL-STRIPMD-002: strips headings', () => { + expect(stripMarkdown('# Heading 1')).toBe('Heading 1'); + expect(stripMarkdown('## Heading 2')).toBe('Heading 2'); + expect(stripMarkdown('### Heading 3')).toBe('Heading 3'); + }); + + it('FE-UTIL-STRIPMD-003: converts links to text and removes images', () => { + expect(stripMarkdown('[click here](https://example.com)')).toBe('click here'); + expect(stripMarkdown('![alt text](image.jpg)')).toBe(''); + }); + + it('FE-UTIL-STRIPMD-004: strips code blocks and inline code', () => { + expect(stripMarkdown('use `console.log`')).toBe('use console.log'); + expect(stripMarkdown('```\ncode block\n```')).toBe(''); + }); + + it('FE-UTIL-STRIPMD-005: strips blockquotes and lists', () => { + expect(stripMarkdown('> quoted text')).toBe('quoted text'); + expect(stripMarkdown('- item one')).toBe('item one'); + expect(stripMarkdown('1. first item')).toBe('first item'); + }); + + it('FE-UTIL-STRIPMD-006: strips strikethrough and horizontal rules', () => { + expect(stripMarkdown('~~deleted~~')).toBe('deleted'); + expect(stripMarkdown('---')).toBe(''); + }); +}); diff --git a/client/src/components/Journey/stripMarkdown.ts b/client/src/components/Journey/stripMarkdown.ts new file mode 100644 index 00000000..f600a9b2 --- /dev/null +++ b/client/src/components/Journey/stripMarkdown.ts @@ -0,0 +1,24 @@ +/** + * Strip markdown formatting to get plain text for previews. + * Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr. + */ +export function stripMarkdown(md: string): string { + return md + .replace(/^#{1,6}\s+/gm, '') // headings + .replace(/!\[.*?\]\(.*?\)/g, '') // images + .replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text + .replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks + .replace(/`([^`]+)`/g, '$1') // inline code + .replace(/\*\*(.+?)\*\*/g, '$1') // bold ** + .replace(/__(.+?)__/g, '$1') // bold __ + .replace(/\*(.+?)\*/g, '$1') // italic * + .replace(/_(.+?)_/g, '$1') // italic _ + .replace(/~~(.+?)~~/g, '$1') // strikethrough + .replace(/^>\s?/gm, '') // blockquotes + .replace(/^[-*+]\s+/gm, '') // unordered lists + .replace(/^\d+\.\s+/gm, '') // ordered lists + .replace(/^---+$/gm, '') // horizontal rules + .replace(/\n{2,}/g, ' ') // collapse multiple newlines + .replace(/\n/g, ' ') // remaining newlines → spaces + .trim() +} diff --git a/client/src/components/Layout/BottomNav.test.tsx b/client/src/components/Layout/BottomNav.test.tsx new file mode 100644 index 00000000..d603a6a9 --- /dev/null +++ b/client/src/components/Layout/BottomNav.test.tsx @@ -0,0 +1,102 @@ +// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009 + +vi.mock('../../api/websocket', () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + getSocketId: vi.fn(() => null), + setRefetchCallback: vi.fn(), + setPreReconnectHook: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), +})); + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +import { render, screen, fireEvent } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { useAuthStore } from '../../store/authStore'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { buildUser } from '../../../tests/helpers/factories'; +import BottomNav from './BottomNav'; + +const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' }); + +beforeEach(() => { + resetAllStores(); + mockNavigate.mockClear(); + seedStore(useAuthStore, { user: currentUser, isAuthenticated: true }); +}); + +describe('BottomNav', () => { + it('FE-COMP-BOTTOMNAV-001: renders without crashing', () => { + render(); + expect(document.body).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => { + render(); + expect(screen.getByText('Trips')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => { + render(); + expect(screen.getByText('Profile')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + // Profile sheet shows username + expect(screen.getByText('testuser')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + expect(screen.getByText('testuser')).toBeInTheDocument(); + expect(screen.getByText('test@example.com')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => { + const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' }); + seedStore(useAuthStore, { user: adminUser, isAuthenticated: true }); + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Profile')); + // Sheet is open — username visible + expect(screen.getByText('testuser')).toBeInTheDocument(); + // The outermost fixed div is the backdrop wrapper, clicking it triggers onClose + const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; + expect(backdrop).toBeTruthy(); + fireEvent.click(backdrop); + // Sheet should be closed — username no longer visible (only the nav Profile text remains) + expect(screen.queryByText('testuser')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Layout/BottomNav.tsx b/client/src/components/Layout/BottomNav.tsx new file mode 100644 index 00000000..16aa9ffd --- /dev/null +++ b/client/src/components/Layout/BottomNav.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react' +import { NavLink, useNavigate } from 'react-router-dom' +import { useAddonStore } from '../../store/addonStore' +import { useAuthStore } from '../../store/authStore' +import { useSettingsStore } from '../../store/settingsStore' +import { useTranslation } from '../../i18n' +import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [ + { to: '/trips', label: 'Trips', icon: Plane }, +] + +const ADDON_NAV: Record = { + vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays }, + atlas: { to: '/atlas', label: 'Atlas', icon: Globe }, + journey: { to: '/journey', label: 'Journey', icon: Compass }, +} + +export default function BottomNav() { + const { t } = useTranslation() + const darkMode = useSettingsStore(s => s.settings.dark_mode) + const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const addons = useAddonStore(s => s.addons) + const globalAddons = addons.filter(a => a.type === 'global' && a.enabled) + const [showProfile, setShowProfile] = useState(false) + + const items = [...BASE_ITEMS] + for (const addon of globalAddons) { + const nav = ADDON_NAV[addon.id] + if (nav) items.push(nav) + } + + return ( + <> + + + {showProfile && setShowProfile(false)} />} + + ) +} + +function ProfileSheet({ onClose }: { onClose: () => void }) { + const { t } = useTranslation() + const { user, logout } = useAuthStore() + const navigate = useNavigate() + + const handleNav = (path: string) => { + onClose() + navigate(path) + } + + const handleLogout = () => { + onClose() + logout() + navigate('/login') + } + + return ( +
    + {/* Backdrop */} +
    + + {/* Sheet */} +
    e.stopPropagation()} + > + {/* Handle */} +
    +
    +
    + + {/* User info */} +
    +
    +
    + {(user?.username || '?')[0].toUpperCase()} +
    +
    +

    {user?.username}

    +

    {user?.email}

    +
    + {user?.role === 'admin' && ( + + Admin + + )} +
    +
    + +
    + + {/* Links */} +
    + + + {user?.role === 'admin' && ( + + )} +
    + +
    + + {/* Logout */} +
    + +
    + +
    +
    +
    + ) +} diff --git a/client/src/components/Layout/MobileTopHeader.test.tsx b/client/src/components/Layout/MobileTopHeader.test.tsx new file mode 100644 index 00000000..b8adca13 --- /dev/null +++ b/client/src/components/Layout/MobileTopHeader.test.tsx @@ -0,0 +1,32 @@ +// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004 + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '../../../tests/helpers/render'; +import MobileTopHeader from './MobileTopHeader'; + +describe('MobileTopHeader', () => { + it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toBeInTheDocument(); + expect(heading.textContent).toBe('Journeys'); + }); + + it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => { + render(); + expect(screen.getByText('3 trips')).toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => { + const { container } = render(); + const subtitleEl = container.querySelector('.text-xs.text-zinc-500'); + expect(subtitleEl).not.toBeInTheDocument(); + }); + + it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => { + render( + Add} />, + ); + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Layout/MobileTopHeader.tsx b/client/src/components/Layout/MobileTopHeader.tsx new file mode 100644 index 00000000..4f6f3052 --- /dev/null +++ b/client/src/components/Layout/MobileTopHeader.tsx @@ -0,0 +1,17 @@ +interface Props { + title: string + subtitle?: string + actions?: React.ReactNode +} + +export default function MobileTopHeader({ title, subtitle, actions }: Props) { + return ( +
    +
    +

    {title}

    + {subtitle &&
    {subtitle}
    } +
    + {actions &&
    {actions}
    } +
    + ) +} diff --git a/client/src/components/Layout/Navbar.test.tsx b/client/src/components/Layout/Navbar.test.tsx index e76f70f0..b0f8df24 100644 --- a/client/src/components/Layout/Navbar.test.tsx +++ b/client/src/components/Layout/Navbar.test.tsx @@ -16,7 +16,7 @@ beforeEach(() => { http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })), http.get('/api/addons', () => HttpResponse.json({ addons: [] })), ); - seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true }); + seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' }); seedStore(useSettingsStore, { settings: buildSettings() }); }); diff --git a/client/src/components/Layout/Navbar.tsx b/client/src/components/Layout/Navbar.tsx index cee59b8b..a6bd7a92 100644 --- a/client/src/components/Layout/Navbar.tsx +++ b/client/src/components/Layout/Navbar.tsx @@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useTranslation } from '../../i18n' -import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react' +import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import InAppNotificationBell from './InAppNotificationBell.tsx' -const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe } +const ADDON_ICONS: Record = { CalendarDays, Briefcase, Globe, Compass } interface NavbarProps { tripTitle?: string @@ -27,14 +27,13 @@ interface Addon { } export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { - const { user, logout } = useAuthStore() + const { user, logout, isPrerelease, appVersion } = useAuthStore() const { settings, updateSetting } = useSettingsStore() const { addons: allAddons, loadAddons } = useAddonStore() const { t, locale } = useTranslation() const navigate = useNavigate() const location = useLocation() const [userMenuOpen, setUserMenuOpen] = useState(false) - const [appVersion, setAppVersion] = useState(null) const darkMode = settings.dark_mode const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -45,12 +44,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: if (user) loadAddons() }, [user, location.pathname]) - useEffect(() => { - import('../../api/client').then(({ authApi }) => { - authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {}) - }) - }, []) - const handleLogout = () => { logout() navigate('/login', { state: { noRedirect: true } }) @@ -75,7 +68,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: touchAction: 'manipulation', paddingTop: 'env(safe-area-inset-top, 0px)', height: 'var(--nav-h)', - }} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]"> + }} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]"> {/* Left side */}
    {showBack && ( @@ -155,6 +148,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: )} + {/* Prerelease badge */} + {isPrerelease && appVersion && ( + + + {appVersion} + + )} + {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */} +
    +
    + {Object.entries(scopesByGroup).map(([group, groupScopes]) => { + const groupScopeKeys = groupScopes.map(s => s.scope) + const allGroupSelected = groupScopeKeys.every(s => selected.includes(s)) + const someGroupSelected = groupScopeKeys.some(s => selected.includes(s)) + return ( +
    +
    + + { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }} + onChange={e => onChange( + e.target.checked + ? [...new Set([...selected, ...groupScopeKeys])] + : selected.filter(s => !groupScopeKeys.includes(s)) + )} + className="rounded" + title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`} + /> +
    + {open[group] && ( +
    + {groupScopes.map(({ scope, label, description }) => ( + + ))} +
    + )} +
    + ) + })} +
    +
    + ) +} diff --git a/client/src/components/PDF/JourneyBookPDF.test.tsx b/client/src/components/PDF/JourneyBookPDF.test.tsx new file mode 100644 index 00000000..bb43e711 --- /dev/null +++ b/client/src/components/PDF/JourneyBookPDF.test.tsx @@ -0,0 +1,147 @@ +// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006 +// +// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)` +// that opens a new browser window and writes a full HTML document into it. +// It does NOT render a React component. Tests verify window.open behaviour. + +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// Mock `marked` so we don't need the real markdown parser +vi.mock('marked', () => ({ + marked: { + parse: (str: string) => `

    ${str}

    `, + }, +})); + +import { downloadJourneyBookPDF } from './JourneyBookPDF'; +import type { JourneyDetail } from '../../store/journeyStore'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function buildJourney(overrides: Partial = {}): JourneyDetail { + return { + id: 1, + user_id: 1, + title: 'Iceland Ring Road', + subtitle: 'Two weeks around the island', + status: 'active', + cover_image: null, + cover_gradient: null, + created_at: Date.now(), + updated_at: Date.now(), + entries: [ + { + id: 10, + journey_id: 1, + author_id: 1, + type: 'entry', + title: 'Golden Circle', + story: 'An incredible day of geysers and waterfalls.', + entry_date: '2026-07-01', + entry_time: '09:00', + location_name: 'Thingvellir', + location_lat: 64.255, + location_lng: -21.13, + mood: 'excited', + weather: 'sunny', + tags: [], + pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] }, + visibility: 'private', + sort_order: 0, + created_at: Date.now(), + updated_at: Date.now(), + source_trip_id: null, + source_place_id: null, + source_trip_name: null, + photos: [ + { + id: 100, + entry_id: 10, + provider: 'local', + file_path: 'journey/geyser.jpg', + thumbnail_path: null, + asset_id: null, + owner_id: null, + shared: 0, + caption: 'Strokkur erupting', + sort_order: 0, + created_at: Date.now(), + }, + ], + }, + ], + trips: [], + contributors: [], + stats: { entries: 1, photos: 1, cities: 1 }, + ...overrides, + } as unknown as JourneyDetail; +} + +// ── Mock window.open ───────────────────────────────────────────────────────── + +let mockWindow: { + document: { write: ReturnType; close: ReturnType }; + focus: ReturnType; +}; + +beforeEach(() => { + mockWindow = { + document: { write: vi.fn(), close: vi.fn() }, + focus: vi.fn(), + }; + vi.spyOn(window, 'open').mockReturnValue(mockWindow as any); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('downloadJourneyBookPDF', () => { + it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => { + await downloadJourneyBookPDF(buildJourney()); + expect(window.open).toHaveBeenCalledWith('', '_blank'); + }); + + it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => { + await downloadJourneyBookPDF(buildJourney()); + expect(mockWindow.document.write).toHaveBeenCalledTimes(1); + const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => { + await downloadJourneyBookPDF(buildJourney()); + expect(mockWindow.document.close).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => { + await downloadJourneyBookPDF(buildJourney()); + const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain('Iceland Ring Road'); + }); + + it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => { + await downloadJourneyBookPDF(buildJourney()); + const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain('Golden Circle'); + // Story text is rendered via markdown + expect(html).toContain('An incredible day of geysers and waterfalls.'); + // Pros/cons verdict cards are included + expect(html).toContain('Amazing views'); + expect(html).toContain('Crowded'); + }); + + it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => { + const journey = buildJourney({ entries: [] }); + await downloadJourneyBookPDF(journey); + expect(window.open).toHaveBeenCalled(); + const html = mockWindow.document.write.mock.calls[0][0] as string; + expect(html).toContain('Iceland Ring Road'); + // No entry pages, but cover and closing page are still present + expect(html).toContain('Journey Book'); + expect(html).toContain('The End'); + }); +}); diff --git a/client/src/components/PDF/JourneyBookPDF.tsx b/client/src/components/PDF/JourneyBookPDF.tsx new file mode 100644 index 00000000..80d38333 --- /dev/null +++ b/client/src/components/PDF/JourneyBookPDF.tsx @@ -0,0 +1,306 @@ +// Journey Photo Book PDF — Polarsteps-inspired, magazine-density +import { marked } from 'marked' +import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore' + +function esc(str: string | null | undefined): string { + if (!str) return '' + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function md(str: string | null | undefined): string { + if (!str) return '' + return marked.parse(str, { async: false, breaks: true }) as string +} + +function abs(url: string | null | undefined): string { + if (!url) return '' + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url + return window.location.origin + (url.startsWith('/') ? '' : '/') + url +} + +function pSrc(p: JourneyPhoto): string { + return abs(`/api/photos/${p.photo_id}/original`) +} + +function fmtDate(d: string): string { + const date = new Date(d + 'T00:00:00') + return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }) +} + +function fmtShort(d: string): string { + return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' }) +} + +function groupByDate(entries: JourneyEntry[]): Map { + const groups = new Map() + for (const e of entries) { + if (!e.entry_date) continue + if (!groups.has(e.entry_date)) groups.set(e.entry_date, []) + groups.get(e.entry_date)!.push(e) + } + return groups +} + +function renderProscons(entry: JourneyEntry): string { + const pc = entry.pros_cons + if (!pc) return '' + const pros = pc.pros?.filter(p => p.trim()) || [] + const cons = pc.cons?.filter(c => c.trim()) || [] + if (pros.length === 0 && cons.length === 0) return '' + + return `
    + ${pros.length > 0 ? `
    Loved it
      ${pros.map(p => `
    • ${esc(p)}
    • `).join('')}
    ` : ''} + ${cons.length > 0 ? `
    Could be better
      ${cons.map(c => `
    • ${esc(c)}
    • `).join('')}
    ` : ''} +
    ` +} + +function renderPhotoBlock(photos: JourneyPhoto[]): string { + if (photos.length === 0) return '' + if (photos.length === 1) { + return `
    ` + } + if (photos.length === 2) { + return `
    ${photos.map(p => `
    `).join('')}
    ` + } + // 3+ photos: hero left + stack right + return `
    +
    +
    +
    +
    +
    +
    ` +} + +export async function downloadJourneyBookPDF(journey: JourneyDetail) { + const entries = (journey.entries || []).filter(e => e.type !== 'skeleton' && e.type !== 'gallery') + const allPhotos = entries.flatMap(e => e.photos || []) + const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '') + + const grouped = groupByDate(entries) + const dates = [...grouped.keys()].sort() + + // Build entry pages — one per entry, day header inline on first entry of day + const entryPages: string[] = [] + let pageNum = 1 // cover=1 + dates.forEach((date, di) => { + const dayEntries = grouped.get(date)! + dayEntries.forEach((entry, ei) => { + pageNum++ + const isFirstOfDay = ei === 0 + const photos = entry.photos || [] + const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ') + + // Day header (inline, only on first entry of day) + const dayHeaderHtml = isFirstOfDay + ? `
    Day ${di + 1} · ${fmtDate(date)}
    ` + : '' + + // Photo block + const photoHtml = renderPhotoBlock(photos) + + // Pros/cons + const prosconsHtml = renderProscons(entry) + + // Story (markdown) + const storyHtml = entry.story ? `
    ${md(entry.story)}
    ` : '' + + entryPages.push(` +
    + ${dayHeaderHtml} + ${photoHtml} +
    + ${meta ? `` : ''} + ${entry.title ? `

    ${esc(entry.title)}

    ` : ''} + ${storyHtml} + ${prosconsHtml} +
    +
    + `) + }) + }) + + const totalPages = pageNum + 1 // +1 for closing page + + const html = ` + + + + +${esc(journey.title)} — Journey Book + + + + + + + +
    + ${coverUrl ? `
    ` : ''} +
    +
    +
    +
    Journey Book
    +

    ${esc(journey.title)}

    + ${journey.subtitle ? `
    ${esc(journey.subtitle)}
    ` : ''} +
    +
    ${dates.length}
    Days
    +
    ${entries.length}
    Entries
    +
    ${allPhotos.length}
    Photos
    +
    +
    + +
    + + + ${entryPages.join('\n')} + + +
    +
    +
    The End
    +
    Made with TREK · ${new Date().getFullYear()}
    +
    +
    + + +` + + const win = window.open('', '_blank') + if (!win) return + win.document.write(html) + win.document.close() +} diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 2ef5fdc7..1491a4cf 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -1,7 +1,7 @@ // Trip PDF via browser print window import { createElement } from 'react' import { getCategoryIcon } from '../shared/categoryIcons' -import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react' +import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react' import { accommodationsApi, mapsApi } from '../../api/client' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' @@ -18,10 +18,12 @@ function noteIconSvg(iconId) { return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }) } -const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } -function transportIconSvg(type) { - const Icon = TRANSPORT_ICON_MAP[type] || Ticket - return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }) +const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText } +const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' } +function reservationIconSvg(type) { + const Icon = RESERVATION_ICON_MAP[type] || Ticket + const color = RESERVATION_COLOR_MAP[type] || '#3b82f6' + return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color }) } const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound } @@ -144,19 +146,18 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const notes = (dayNotes || []).filter(n => n.day_id === day.id) const cost = dayCost(assignments, day.id, loc) - // Transport bookings for this day - const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) - const dayTransport = (reservations || []).filter(r => { - if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false + // Reservations for this day (hotel rendered via accommodations block) + const dayReservations = (reservations || []).filter(r => { + if (!r.reservation_time || r.type === 'hotel') return false return day.date && r.reservation_time.split('T')[0] === day.date }) const merged = [] assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) - dayTransport.forEach(r => { + dayReservations.forEach(r => { const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) - merged.push({ type: 'transport', k: pos, data: r }) + merged.push({ type: 'reservation', k: pos, data: r }) }) merged.sort((a, b) => a.k - b.k) @@ -164,21 +165,27 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const itemsHtml = merged.length === 0 ? `
    ${escHtml(tr('dayplan.emptyDay'))}
    ` : merged.map(item => { - if (item.type === 'transport') { + if (item.type === 'reservation') { const r = item.data const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) - const icon = transportIconSvg(r.type) + const icon = reservationIconSvg(r.type) + const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6' let subtitle = '' if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ') else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ') + else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ') + else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ') + else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ') + const locationLine = r.location || meta.location || '' const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' return ` -
    -
    +
    +
    ${icon}
    ${escHtml(r.title)}${time ? ` ${time}` : ''}
    ${subtitle ? `
    ${escHtml(subtitle)}
    ` : ''} + ${locationLine ? `
    ${escHtml(locationLine)}
    ` : ''} ${r.confirmation_number ? `
    Code: ${escHtml(r.confirmation_number)}
    ` : ''}
    ` diff --git a/client/src/components/Packing/PackingListPanel.tsx b/client/src/components/Packing/PackingListPanel.tsx index ce856501..d5745476 100644 --- a/client/src/components/Packing/PackingListPanel.tsx +++ b/client/src/components/Packing/PackingListPanel.tsx @@ -467,6 +467,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on const [showAddItem, setShowAddItem] = useState(false) const [newItemName, setNewItemName] = useState('') const addItemRef = useRef(null) + const menuBtnRef = useRef(null) const assigneeDropdownRef = useRef(null) const { togglePackingItem } = useTripStore() const toast = useToast() @@ -629,22 +630,27 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
    - - {showMenu && ( -
    setShowMenu(false)}> - {canEdit && } label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />} - } label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} /> - } label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} /> - {canEdit && <> -
    - } label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} /> - } -
    - )} + {showMenu && (() => { + const rect = menuBtnRef.current?.getBoundingClientRect(); + return ( + <> +
    setShowMenu(false)} /> +
    + {canEdit && } label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />} + } label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} /> + } label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} /> + {canEdit && <> +
    + } label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} /> + } +
    + + ); + })()}
    diff --git a/client/src/components/Photos/PhotoLightbox.tsx b/client/src/components/Photos/PhotoLightbox.tsx index cbd483b5..ba6a5738 100644 --- a/client/src/components/Photos/PhotoLightbox.tsx +++ b/client/src/components/Photos/PhotoLightbox.tsx @@ -149,7 +149,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet value={caption} onChange={e => setCaption(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSaveCaption()} - placeholder="Beschriftung hinzufügen..." + placeholder={t('photos.addCaption')} className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40" autoFocus /> @@ -173,7 +173,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet className="text-white text-sm flex-1 cursor-pointer hover:text-white/80" onClick={() => setEditCaption(true)} > - {photo.caption || Beschriftung hinzufügen...} + {photo.caption || {t('photos.addCaption')}}

    @@ -98,7 +98,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp {/* Preview grid */} {files.length > 0 && (
    -

    {files.length} Foto{files.length !== 1 ? 's' : ''} ausgewählt

    +

    {files.length} {t(files.length !== 1 ? 'photos.photosSelected' : 'photos.photoSelected')}

    {files.map((file, idx) => (
    @@ -126,15 +126,15 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp {files.length > 0 && (
    - +
    @@ -152,12 +152,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
    - + setCaption(e.target.value)} - placeholder="Optionale Beschriftung..." + placeholder={t('photos.captionPlaceholder')} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900" />
    @@ -169,7 +169,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
    - Wird hochgeladen... + {t('common.uploading')}
    +
    {!collapsed && formattedDate &&
    {formattedDate}
    }
    - + ) : ( +
    +
    + setSearch(e.target.value)} + placeholder={t('dayplan.mobile.searchPlaces')} + style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: 'var(--text-primary)' }} + /> + +
    +
    + {filtered.length === 0 && ( +
    + {available.length === 0 ? t('dayplan.mobile.allAssigned') : t('dayplan.mobile.noMatch')} +
    + )} + {filtered.slice(0, 20).map(p => ( + + ))} +
    + {onAddNew && ( + + )} +
    + )} +
    + ) +} + interface DayPlanSidebarProps { tripId: number trip: Trip @@ -79,6 +172,8 @@ interface DayPlanSidebarProps { reservations?: Reservation[] onAddReservation: () => void onNavigateToFiles?: () => void + onAddPlace?: () => void + onAddPlaceToDay?: (placeId: number, dayId: number) => void onExpandedDaysChange?: (expandedDayIds: Set) => void pushUndo?: (label: string, undoFn: () => Promise | void) => void canUndo?: boolean @@ -95,6 +190,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], onAddReservation, + onAddPlace, + onAddPlaceToDay, onNavigateToFiles, onExpandedDaysChange, pushUndo, @@ -519,7 +616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) }) } - } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } + } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { @@ -606,7 +703,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ tripActions.setAssignments(currentAssignments) } } catch (err) { - toast.error(err instanceof Error ? err.message : 'Unknown error') + toast.error(err instanceof Error ? err.message : t('common.unknownError')) return } @@ -755,9 +852,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex) }) }) - .catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + .catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId && fromDayId !== dayId) { - tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null) setDropTargetKey(null) @@ -862,7 +959,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ a.download = `${trip?.title || 'trip'}.ics` a.click() URL.revokeObjectURL(url) - } catch { toast.error('ICS export failed') } + } catch { toast.error(t('planner.icsExportFailed')) } }} onMouseEnter={() => setIcsHover(true)} onMouseLeave={() => setIcsHover(false)} @@ -1089,11 +1186,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (assignmentId && fromDayId !== day.id) { - tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (assignmentId) { handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter) } else if (noteId && fromDayId !== day.id) { - tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter) } @@ -1107,11 +1204,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ setDropTargetKey(null); window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { - tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { - tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) @@ -1207,7 +1304,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ setDropTargetKey(null); window.__dragData = null } else if (fromAssignmentId && fromDayId !== day.id) { const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id) - tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id) @@ -1215,7 +1312,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 - tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) @@ -1227,7 +1324,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, - (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') }, + (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') }, { divider: true }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, ])} @@ -1411,11 +1508,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (fromAssignmentId && fromDayId !== day.id) { - tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter) } else if (noteId && fromDayId !== day.id) { - tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter) } @@ -1499,7 +1596,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 - tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null) } else if (fromNoteId && fromNoteId !== String(note.id)) { handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id) @@ -1507,7 +1604,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length - tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) @@ -1572,11 +1669,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { - tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { - tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) @@ -1623,6 +1720,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
    )} + + {/* Mobile: Add Place from list */} +
    )}
    diff --git a/client/src/components/Planner/FileImportModal.tsx b/client/src/components/Planner/FileImportModal.tsx new file mode 100644 index 00000000..687e1d14 --- /dev/null +++ b/client/src/components/Planner/FileImportModal.tsx @@ -0,0 +1,304 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { useState, useRef, useEffect } from 'react' +import { Upload } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { placesApi } from '../../api/client' +import { useTripStore } from '../../store/tripStore' + +interface PlacesImportSummary { + totalPlacemarks: number + createdCount: number + skippedCount: number + warnings: string[] + errors: string[] +} + +interface FileImportModalProps { + isOpen: boolean + onClose: () => void + tripId: number + pushUndo?: (label: string, undoFn: () => Promise | void) => void + initialFile?: File | null +} + +const MAX_FILE_BYTES = 10 * 1024 * 1024 + +export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, initialFile }: FileImportModalProps) { + const { t } = useTranslation() + const toast = useToast() + const loadTrip = useTripStore((s) => s.loadTrip) + const fileInputRef = useRef(null) + + const [file, setFile] = useState(null) + const [isDragOver, setIsDragOver] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [summary, setSummary] = useState(null) + + const validateFile = (f: File): string | null => { + const ext = f.name.toLowerCase().split('.').pop() + if (ext !== 'gpx' && ext !== 'kml' && ext !== 'kmz') { + return t('places.importFileUnsupported') + } + if (f.size > MAX_FILE_BYTES) { + return t('places.importFileTooLarge', { maxMb: 10 }) + } + return null + } + + const reset = () => { + setFile(null) + setIsDragOver(false) + setLoading(false) + setError('') + setSummary(null) + } + + // When the modal opens, reset state and pre-load any file dropped from the sidebar. + useEffect(() => { + if (!isOpen) return + setIsDragOver(false) + setLoading(false) + setSummary(null) + if (initialFile) { + const err = validateFile(initialFile) + if (err) { + setFile(null) + setError(err) + } else { + setFile(initialFile) + setError('') + } + } else { + setFile(null) + setError('') + } + // validateFile uses t() which is stable — intentionally omitted from deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, initialFile]) + + const handleClose = () => { + reset() + onClose() + } + + const selectFile = (f: File) => { + const validationError = validateFile(f) + if (validationError) { + setError(validationError) + setFile(null) + return + } + setFile(f) + setError('') + setSummary(null) + } + + const handleInputChange = (e: React.ChangeEvent) => { + const f = e.target.files?.[0] + e.target.value = '' + if (f) selectFile(f) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + if (e.target === e.currentTarget) setIsDragOver(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + const f = e.dataTransfer.files[0] + if (f) selectFile(f) + } + + const handleImport = async () => { + if (!file || loading) return + const ext = file.name.toLowerCase().split('.').pop() + setLoading(true) + setError('') + setSummary(null) + + try { + if (ext === 'gpx') { + const result = await placesApi.importGpx(tripId, file) + await loadTrip(tripId) + if (result.count === 0 && result.skipped > 0) { + toast.warning(t('places.importAllSkipped')) + } else { + toast.success(t('places.gpxImported', { count: result.count })) + } + if (result.places?.length > 0) { + const importedIds: number[] = result.places.map((p: { id: number }) => p.id) + pushUndo?.(t('undo.importGpx'), async () => { + for (const id of importedIds) { + try { await placesApi.delete(tripId, id) } catch {} + } + await loadTrip(tripId) + }) + } + handleClose() + } else { + const result = await placesApi.importMapFile(tripId, file) + await loadTrip(tripId) + setSummary(result.summary || null) + if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) { + toast.warning(t('places.importAllSkipped')) + } else { + toast.success(t('places.kmlKmzImported', { count: result.count })) + } + if (result.summary?.errors?.length > 0) { + setError(result.summary.errors.join('\n')) + } + if (result.places?.length > 0) { + const importedIds: number[] = result.places.map((p: { id: number }) => p.id) + pushUndo?.(t('undo.importKeyholeMarkup'), async () => { + for (const id of importedIds) { + try { await placesApi.delete(tripId, id) } catch {} + } + await loadTrip(tripId) + }) + } + } + } catch (err: any) { + const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined + if (responseSummary) setSummary(responseSummary) + const message = err?.response?.data?.error || t('places.importFileError') + setError(message) + toast.error(message) + } finally { + setLoading(false) + } + } + + const canImport = !!file && !loading + + if (!isOpen) return null + + return ReactDOM.createPortal( +
    +
    e.stopPropagation()} + style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} + > +
    + {t('places.importFile')} +
    +
    + {t('places.importFileHint')} +
    + + + +
    fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDragEnter={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + style={{ + width: '100%', + minHeight: 88, + borderRadius: 12, + border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`, + background: isDragOver ? 'var(--bg-tertiary)' : 'transparent', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + fontSize: 13, + fontWeight: 500, + cursor: 'pointer', + marginBottom: 12, + fontFamily: 'inherit', + transition: 'border-color 0.15s, background 0.15s', + boxSizing: 'border-box', + padding: 16, + }} + > + + {isDragOver ? ( + {t('places.importFileDropActive')} + ) : file ? ( + {file.name} + ) : ( + {t('places.importFileDropHere')} + )} +
    + + {summary && ( +
    +
    + {t('places.kmlKmzSummaryValues', { + total: summary.totalPlacemarks, + created: summary.createdCount, + skipped: summary.skippedCount, + })} +
    + {summary.warnings?.length > 0 && ( +
    + {summary.warnings.join('\n')} +
    + )} +
    + )} + + {error && ( +
    + {error} +
    + )} + +
    + + +
    +
    +
    , + document.body + ) +} diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 3d30f1a0..1b1588c9 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react' +import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { mapsApi } from '../../api/client' @@ -6,7 +6,7 @@ import { useAuthStore } from '../../store/authStore' import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { useToast } from '../shared/Toast' -import { Search, Paperclip, X, AlertTriangle } from 'lucide-react' +import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react' import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import type { Place, Category, Assignment } from '../../types' @@ -25,6 +25,25 @@ interface PlaceFormData { website: string } +function isGoogleMapsUrl(input: string): boolean { + try { + const { hostname, pathname } = new URL(input.trim()) + const h = hostname.toLowerCase() + // maps.app.goo.gl, goo.gl/maps + if (h === 'maps.app.goo.gl') return true + if (h === 'goo.gl' && pathname.startsWith('/maps')) return true + // maps.google.* (e.g. maps.google.com, maps.google.co.uk) + // Must be maps.google. or maps.google.. — reject maps.google.evil.com + if (/^maps\.google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(h)) return true + // google.*/maps (e.g. google.com/maps, www.google.co.uk/maps) + const bare = h.startsWith('www.') ? h.slice(4) : h + if (/^google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(bare) && pathname.startsWith('/maps')) return true + return false + } catch { + return false + } +} + const DEFAULT_FORM: PlaceFormData = { name: '', description: '', @@ -65,6 +84,10 @@ export default function PlaceFormModal({ const [isSaving, setIsSaving] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const fileRef = useRef(null) + const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([]) + const [acHighlight, setAcHighlight] = useState(-1) + const acDebounceRef = useRef | null>(null) + const acAbortRef = useRef(null) const toast = useToast() const { t, language } = useTranslation() const { hasMapsKey } = useAuthStore() @@ -101,6 +124,73 @@ export default function PlaceFormModal({ setPendingFiles([]) }, [place, prefillCoords, isOpen]) + // Derive location bias bounding box from the trip's existing places + const places = useTripStore((s) => s.places) + const locationBias = useMemo(() => { + const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null) + if (withCoords.length === 0) return undefined + + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity + for (const p of withCoords) { + const lat = Number(p.lat), lng = Number(p.lng) + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + if (lng < minLng) minLng = lng + if (lng > maxLng) maxLng = lng + } + if (!Number.isFinite(minLat)) return undefined + + // Skip bias if the bounding box is too large (~500 km diagonal) + const dlat = maxLat - minLat + const dlng = maxLng - minLng + const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180) + const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2) + if (diagKm > 500) return undefined + + return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } } + }, [places]) + + // Autocomplete fetch — aborts any in-flight request before starting a new one + const fetchSuggestions = useCallback(async (query: string) => { + if (query.length < 2 || isGoogleMapsUrl(query)) { + setAcSuggestions([]) + setAcHighlight(-1) + return + } + acAbortRef.current?.abort() + const controller = new AbortController() + acAbortRef.current = controller + try { + const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal) + setAcSuggestions(result.suggestions || []) + setAcHighlight(-1) + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') return + if (err instanceof Error && err.name === 'CanceledError') return // axios abort + console.error('Autocomplete failed:', err) + setAcSuggestions([]) + } + }, [language, locationBias]) + + // Debounce effect — only watches mapsSearch + useEffect(() => { + if (acDebounceRef.current) clearTimeout(acDebounceRef.current) + + const trimmed = mapsSearch.trim() + if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) { + setAcSuggestions([]) + setAcHighlight(-1) + return + } + + acDebounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300) + + return () => { + if (acDebounceRef.current) clearTimeout(acDebounceRef.current) + } + }, [mapsSearch, fetchSuggestions]) + const handleChange = (field, value) => { setForm(prev => ({ ...prev, [field]: value })) } @@ -111,7 +201,7 @@ export default function PlaceFormModal({ try { // Detect Google Maps URLs and resolve them directly const trimmed = mapsSearch.trim() - if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) { + if (isGoogleMapsUrl(trimmed)) { const resolved = await mapsApi.resolveUrl(trimmed) if (resolved.lat && resolved.lng) { setForm(prev => ({ @@ -152,6 +242,56 @@ export default function PlaceFormModal({ setMapsSearch('') } + const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => { + setAcSuggestions([]) + setAcHighlight(-1) + const previousSearch = mapsSearch + setMapsSearch('') + setForm(prev => ({ ...prev, name: suggestion.mainText })) + setIsSearchingMaps(true) + try { + const result = await mapsApi.details(suggestion.placeId, language) + if (result.place) { + handleSelectMapsResult(result.place) + } else { + setMapsSearch(previousSearch) + toast.error(t('places.mapsSearchError')) + } + } catch (err) { + console.error('Failed to fetch place details:', err) + setMapsSearch(previousSearch) + toast.error(t('places.mapsSearchError')) + } finally { + setIsSearchingMaps(false) + } + } + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (acSuggestions.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault() + setAcHighlight(prev => (prev + 1) % acSuggestions.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setAcHighlight(prev => (prev <= 0 ? acSuggestions.length - 1 : prev - 1)) + } else if (e.key === 'Enter') { + e.preventDefault() + if (acHighlight >= 0) { + handleSelectSuggestion(acSuggestions[acHighlight]) + } else { + setAcSuggestions([]) + handleMapsSearch() + } + } else if (e.key === 'Escape') { + setAcSuggestions([]) + setAcHighlight(-1) + } + } else if (e.key === 'Enter') { + e.preventDefault() + handleMapsSearch() + } + } + const handleCreateCategory = async () => { if (!newCategoryName.trim()) return try { @@ -229,24 +369,56 @@ export default function PlaceFormModal({ {t('places.osmActive')}

    )} -
    - setMapsSearch(e.target.value)} - onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())} - placeholder={t('places.mapsSearchPlaceholder')} - className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white" - /> - +
    +
    + setMapsSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + onBlur={() => setTimeout(() => setAcSuggestions([]), 150)} + onFocus={() => { + if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) { + fetchSuggestions(mapsSearch.trim()) + } + }} + placeholder={t('places.mapsSearchPlaceholder')} + className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white" + /> + +
    + + {/* Autocomplete dropdown */} + {acSuggestions.length > 0 && ( +
    + {acSuggestions.map((s, idx) => ( + + ))} +
    + )}
    + + {/* Search results (populated after full search) */} {mapsResults.length > 0 && (
    {mapsResults.map((result, idx) => ( @@ -267,14 +439,21 @@ export default function PlaceFormModal({ {/* Name */}
    - handleChange('name', e.target.value)} - required - placeholder={t('places.formNamePlaceholder')} - className="form-input" - /> +
    + handleChange('name', e.target.value)} + required + placeholder={t('places.formNamePlaceholder')} + className="form-input" + /> + {isSearchingMaps && ( +
    +
    + )} +
    {/* Description */} @@ -285,7 +464,20 @@ export default function PlaceFormModal({ onChange={e => handleChange('description', e.target.value)} rows={2} placeholder={t('places.formDescriptionPlaceholder')} - className="form-input" style={{ resize: 'none' }} + className="form-input" style={{ resize: 'vertical' }} + /> +
    + + {/* Notes */} +
    + +