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 @@
-
+
@@ -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 (
+
+ )
}
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')}
-
-
setDeleteConfirmId(token.id)}
- className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
- style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
-
-
+ ) : 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 && (
+ toggleScopes(session.id)}
+ className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
+ style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
+ +{hidden} more
+
+ )}
+ {expanded && hidden > 0 && (
+ toggleScopes(session.id)}
+ className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
+ style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
+ show less
+
+ )}
+
+
+
+
+ {session.username}
+
+
+ {new Date(session.created_at).toLocaleDateString(locale)}
+
+
setRevokeConfirmId(session.id)}
+ className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
+ style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
+
+
+
+ )
+ })}
+ >
+ )}
+
+ {/* 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')}
+
+
setDeleteConfirmId(token.id)}
+ className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
+ style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
+
+
+
+ ))}
+ >
+ )}
+
+
+
+ {/* Revoke OAuth session modal */}
+ {revokeConfirmId !== null && (
+
{ if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
+
+
{t('admin.oauthSessions.revokeTitle')}
+
{t('admin.oauthSessions.revokeMessage')}
+
+ setRevokeConfirmId(null)}
+ className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {t('common.cancel')}
+
+ handleRevoke(revokeConfirmId)}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
+ {t('common.delete')}
+
+
+
+
+ )}
+
+ {/* 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' }}>
+
+
+ {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) {
{ const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
+ onClick={() => openFile(file.url).catch(() => {})}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}>
@@ -750,7 +743,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
{previewFile.original_name}
{ const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
+ onClick={() => openFile(previewFile.url).catch(() => {})}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
@@ -778,7 +771,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
title={previewFile.original_name}
>
- { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen
+ openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}
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 }) => ,
+ 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 ``
+}
+
+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 => (
+
apply(a.action)}
+ style={{
+ width: 32, height: 32, borderRadius: 6,
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ background: 'none', border: 'none',
+ color: 'var(--journal-muted)', cursor: 'pointer',
+ flexShrink: 0,
+ }}
+ onMouseEnter={e => e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}
+ onMouseLeave={e => e.currentTarget.style.background = 'none'}
+ >
+
+
+ ))}
+
+ )
+}
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 */}
+
+
+ {/* Next button */}
+ {hasNext && (
+
+
+
+ )}
+
+ {/* Caption — bottom center overlay */}
+ {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('')).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 (
+ <>
+
+ {items.map(({ to, label, icon: Icon }) => (
+
+ `flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
+ isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
+ }`
+ }
+ >
+
+ {label}
+
+ ))}
+ setShowProfile(true)}
+ className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
+ >
+
+ {t("nav.profile")}
+
+
+
+ {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 */}
+
+ handleNav('/settings')}
+ className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
+ >
+
+ {t("nav.bottomSettings")}
+
+
+ {user?.role === 'admin' && (
+ handleNav('/admin')}
+ className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
+ >
+
+ {t("nav.bottomAdmin")}
+
+ )}
+
+
+
+
+ {/* Logout */}
+
+
+
+ {t("nav.bottomLogout")}
+
+
+
+
+
+
+ )
+}
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 */}
{
+ const onOnline = () => setIsOnline(true)
+ const onOffline = () => setIsOnline(false)
+ window.addEventListener('online', onOnline)
+ window.addEventListener('offline', onOffline)
+ return () => {
+ window.removeEventListener('online', onOnline)
+ window.removeEventListener('offline', onOffline)
+ }
+ }, [])
+
+ useEffect(() => {
+ let cancelled = false
+ async function poll() {
+ const n = await mutationQueue.pendingCount()
+ if (!cancelled) setPendingCount(n)
+ }
+ poll()
+ const id = setInterval(poll, POLL_MS)
+ return () => { cancelled = true; clearInterval(id) }
+ }, [])
+
+ const hidden = isOnline && pendingCount === 0
+ if (hidden) return null
+
+ const offline = !isOnline
+ const bg = offline ? '#92400e' : '#1e40af'
+ const text = '#fff'
+
+ const label = offline
+ ? pendingCount > 0
+ ? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
+ : 'Offline'
+ : `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
+
+ return (
+
+ {offline
+ ?
+ :
+ }
+ {label}
+
+ )
+}
diff --git a/client/src/components/Memories/MemoriesPanel.test.tsx b/client/src/components/Memories/MemoriesPanel.test.tsx
index f25a3dce..cbb914a2 100644
--- a/client/src/components/Memories/MemoriesPanel.test.tsx
+++ b/client/src/components/Memories/MemoriesPanel.test.tsx
@@ -233,8 +233,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
- { asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
- { asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
+ { photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
+ { photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
],
})
),
@@ -501,8 +501,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
- { asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
- { asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
+ { photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
+ { photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
@@ -676,8 +676,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
- { asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
- { asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
+ { photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
+ { photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx
index baed3cd3..72c79f18 100644
--- a/client/src/components/Memories/MemoriesPanel.tsx
+++ b/client/src/components/Memories/MemoriesPanel.tsx
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
+ photo_id: number
asset_id: string
provider: string
user_id: number
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
- return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
+ return `/photos/${photo.photo_id}/${what}`
}
function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
- const photo: TripPhoto = {
- asset_id: asset.id,
- provider: asset.provider,
- user_id: userId,
- username: '',
- shared: 0,
- added_at: null
- }
- return buildProviderAssetUrl(photo, what)
+ // Picker photos are not yet saved — use provider-specific URL
+ return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
}
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
// Lightbox
- const [lightboxId, setLightboxId] = useState(null)
+ const [lightboxId, setLightboxId] = useState(null)
const [lightboxUserId, setLightboxUserId] = useState(null)
const [lightboxInfo, setLightboxInfo] = useState(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.delete(buildUnifiedUrl('photos'), {
data: {
- asset_id: photo.asset_id,
- provider: photo.provider,
+ photo_id: photo.photo_id,
},
})
- setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
+ setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id))
} catch { toast.error(t('memories.error.removePhoto')) }
}
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared,
- asset_id: photo.asset_id,
- provider: photo.provider,
+ photo_id: photo.photo_id,
})
setTripPhotos(prev => prev.map(p =>
- p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
+ p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch { toast.error(t('memories.error.toggleSharing')) }
}
@@ -714,6 +706,23 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
return (
+ {/* Disconnected banner — shown when photos exist but provider is unreachable */}
+ {!connected && allVisible.length > 0 && enabledProviders.length > 0 && (
+
+
+
+ {t('memories.providerDisconnectedBanner', {
+ provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ')
+ })}
+
+
+ )}
+
{/* Header */}
@@ -822,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id
return (
-
{
- setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
+ setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -944,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowMobileInfo(false)
}
- const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
+ const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId)
const hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => {
@@ -952,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
- setLightboxId(photo.asset_id)
+ setLightboxId(photo.photo_id)
setLightboxUserId(photo.user_id)
setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
diff --git a/client/src/components/Notifications/InAppNotificationItem.test.tsx b/client/src/components/Notifications/InAppNotificationItem.test.tsx
index f8ac1081..1eb024bc 100644
--- a/client/src/components/Notifications/InAppNotificationItem.test.tsx
+++ b/client/src/components/Notifications/InAppNotificationItem.test.tsx
@@ -1,4 +1,4 @@
-// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-010
+// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
@@ -99,4 +99,109 @@ describe('InAppNotificationItem', () => {
// Recent notification shows "just now"
expect(screen.getByText('just now')).toBeInTheDocument();
});
+
+ it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
+ render(
+
+ );
+ expect(document.querySelector('img')).toBeInTheDocument();
+ expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/avatar.png');
+ });
+
+ it('FE-COMP-NOTIF-012: boolean notification shows Accept and Reject buttons', () => {
+ render(
+
+ );
+ expect(screen.getByText('Yes')).toBeInTheDocument();
+ expect(screen.getByText('No')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-NOTIF-013: clicking Accept calls respondToBoolean with positive', async () => {
+ const user = userEvent.setup();
+ const respondToBoolean = vi.fn().mockResolvedValue(undefined);
+ seedStore(useInAppNotificationStore, { respondToBoolean });
+ render(
+
+ );
+ await user.click(screen.getByText('Yes'));
+ expect(respondToBoolean).toHaveBeenCalledWith(55, 'positive');
+ });
+
+ it('FE-COMP-NOTIF-014: clicking Reject calls respondToBoolean with negative', async () => {
+ const user = userEvent.setup();
+ const respondToBoolean = vi.fn().mockResolvedValue(undefined);
+ seedStore(useInAppNotificationStore, { respondToBoolean });
+ render(
+
+ );
+ await user.click(screen.getByText('No'));
+ expect(respondToBoolean).toHaveBeenCalledWith(66, 'negative');
+ });
+
+ it('FE-COMP-NOTIF-015: navigate notification shows action button', () => {
+ render(
+
+ );
+ // t('notifications.title') = "Notifications" — the navigate button renders this
+ const navigateBtn = document.querySelector('button[style*="pointer"]') ??
+ Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('Notifications'));
+ expect(navigateBtn).toBeInTheDocument();
+ });
+
+ it('FE-COMP-NOTIF-016: clicking navigate button marks read and navigates', async () => {
+ const user = userEvent.setup();
+ const markRead = vi.fn().mockResolvedValue(undefined);
+ const onClose = vi.fn();
+ seedStore(useInAppNotificationStore, { markRead });
+ render(
+
+ );
+ // The navigate button renders t('notifications.title') = "Notifications"
+ const btn = Array.from(document.querySelectorAll('button')).find(
+ b => b.textContent?.includes('Notifications')
+ );
+ expect(btn).toBeTruthy();
+ await user.click(btn!);
+ expect(markRead).toHaveBeenCalledWith(77);
+ expect(onClose).toHaveBeenCalled();
+ });
});
diff --git a/client/src/components/OAuth/ScopeGroupPicker.test.tsx b/client/src/components/OAuth/ScopeGroupPicker.test.tsx
new file mode 100644
index 00000000..1dde39e7
--- /dev/null
+++ b/client/src/components/OAuth/ScopeGroupPicker.test.tsx
@@ -0,0 +1,119 @@
+// FE-COMP-SCOPE-001 to FE-COMP-SCOPE-009
+import { render, screen, waitFor } from '../../../tests/helpers/render';
+import userEvent from '@testing-library/user-event';
+import { resetAllStores } from '../../../tests/helpers/store';
+import ScopeGroupPicker from './ScopeGroupPicker';
+
+beforeEach(() => {
+ resetAllStores();
+});
+
+describe('ScopeGroupPicker', () => {
+ it('FE-COMP-SCOPE-001: renders scope groups', () => {
+ render(
);
+ // Several group headers should be visible
+ expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
+ });
+
+ it('FE-COMP-SCOPE-002: shows Select All button when nothing selected', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
+ });
+
+ it('FE-COMP-SCOPE-003: Select All calls onChange with all scopes', async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
);
+ await user.click(screen.getByRole('button', { name: /select all/i }));
+ expect(onChange).toHaveBeenCalledTimes(1);
+ const called = onChange.mock.calls[0][0] as string[];
+ expect(called.length).toBeGreaterThan(0);
+ });
+
+ it('FE-COMP-SCOPE-004: shows Deselect All button when all selected', async () => {
+ // First collect all scopes by clicking Select All and capturing the callback
+ const user = userEvent.setup();
+ const captured: string[][] = [];
+ const { rerender } = render(
+
captured.push(s)} />
+ );
+ await user.click(screen.getByRole('button', { name: /select all/i }));
+ const allScopes = captured[0];
+
+ // Now rerender with all scopes selected
+ rerender( );
+ expect(screen.getByRole('button', { name: /deselect all/i })).toBeInTheDocument();
+ });
+
+ it('FE-COMP-SCOPE-005: Deselect All calls onChange with empty array', async () => {
+ const user = userEvent.setup();
+ const captured: string[][] = [];
+
+ // Get all scopes first
+ const { rerender } = render(
+ captured.push(s)} />
+ );
+ await user.click(screen.getByRole('button', { name: /select all/i }));
+ const allScopes = captured[0];
+
+ const onChange = vi.fn();
+ rerender( );
+ await user.click(screen.getByRole('button', { name: /deselect all/i }));
+ expect(onChange).toHaveBeenCalledWith([]);
+ });
+
+ it('FE-COMP-SCOPE-006: expanding a group reveals individual scope checkboxes', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ // Groups are collapsed by default — checkboxes for individual scopes not visible
+ const groupToggles = screen.getAllByRole('button').filter(b =>
+ !b.textContent?.toLowerCase().includes('select all') &&
+ !b.textContent?.toLowerCase().includes('deselect all')
+ );
+ // Click the first group expand button
+ await user.click(groupToggles[0]);
+ // Individual scope checkboxes should now appear (more than just group-level ones)
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes.length).toBeGreaterThan(0);
+ });
+
+ it('FE-COMP-SCOPE-007: group checkbox selects all scopes in the group', async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render( );
+
+ const groupCheckboxes = screen.getAllByRole('checkbox');
+ await user.click(groupCheckboxes[0]);
+ expect(onChange).toHaveBeenCalledTimes(1);
+ const called = onChange.mock.calls[0][0] as string[];
+ expect(called.length).toBeGreaterThan(0);
+ });
+
+ it('FE-COMP-SCOPE-008: individual scope toggle adds/removes that scope', async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render( );
+
+ // Expand first group
+ const groupToggles = screen.getAllByRole('button').filter(b =>
+ !b.textContent?.toLowerCase().includes('select all') &&
+ !b.textContent?.toLowerCase().includes('deselect all')
+ );
+ await user.click(groupToggles[0]);
+
+ // There are now individual scope checkboxes — click the second one (first is group-level)
+ const checkboxes = screen.getAllByRole('checkbox');
+ await user.click(checkboxes[1]); // individual scope
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('FE-COMP-SCOPE-009: count badge shown when some scopes selected in group', () => {
+ // Get any single scope key from the first group via Select All trick + manual slice
+ // We'll just select a scope by triggering group checkbox and passing it in
+ const firstGroupScope = 'trips:read'; // known scope from SCOPE_GROUPS
+ render( );
+ // Count badge like "(1/N)" should be visible
+ expect(screen.getByText(/\(\d+\/\d+\)/)).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/OAuth/ScopeGroupPicker.tsx b/client/src/components/OAuth/ScopeGroupPicker.tsx
new file mode 100644
index 00000000..aa69828b
--- /dev/null
+++ b/client/src/components/OAuth/ScopeGroupPicker.tsx
@@ -0,0 +1,96 @@
+import React, { useState } from 'react'
+import { ChevronDown, ChevronRight } from 'lucide-react'
+import { getScopesByGroup } from '../../api/oauthScopes'
+import { useTranslation } from '../../i18n'
+
+interface Props {
+ selected: string[]
+ onChange: (scopes: string[]) => void
+}
+
+export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState>({})
+
+ const scopesByGroup = getScopesByGroup(t)
+ const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
+ const allSelected = allScopeKeys.every(s => selected.includes(s))
+
+ return (
+
+
+ onChange(allSelected ? [] : allScopeKeys)}
+ className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
+
+
+
+ {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 (
+
+
+ setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
+ className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
+ style={{ color: 'var(--text-secondary)' }}>
+ {open[group]
+ ?
+ : }
+ {group}
+ {someGroupSelected && (
+
+ ({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
+
+ )}
+
+ { 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 }) => (
+
+ onChange(
+ e.target.checked
+ ? [...selected, scope]
+ : selected.filter(s => s !== scope)
+ )}
+ className="mt-0.5 rounded flex-shrink-0"
+ />
+
+
{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
+ ? ``
+ : ''
+
+ // 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 ? `
${esc(meta)}
` : ''}
+ ${entry.title ? `
${esc(entry.title)} ` : ''}
+ ${storyHtml}
+ ${prosconsHtml}
+
+
+ `)
+ })
+ })
+
+ const totalPages = pageNum + 1 // +1 for closing page
+
+ const html = `
+
+
+
+
+${esc(journey.title)} — Journey Book
+
+
+
+
+
+ ${esc(journey.title)} · ${totalPages} pages
+ Save as PDF
+ Close
+
+
+
+
+ ${coverUrl ? `
` : ''}
+
+
+
+
Journey Book
+
${esc(journey.title)}
+ ${journey.subtitle ? `
${esc(journey.subtitle)}
` : ''}
+
+
+
+
${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
-
setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
+ setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
- {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')} }
setEditCaption(true)}
diff --git a/client/src/components/Photos/PhotoUpload.test.tsx b/client/src/components/Photos/PhotoUpload.test.tsx
index 13bf07f4..cbd6e0ea 100644
--- a/client/src/components/Photos/PhotoUpload.test.tsx
+++ b/client/src/components/Photos/PhotoUpload.test.tsx
@@ -43,15 +43,15 @@ describe('PhotoUpload', () => {
it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => {
render( )
- expect(screen.getByText('Fotos hier ablegen')).toBeInTheDocument()
+ expect(screen.getByText('Drop photos here')).toBeInTheDocument()
// Upload icon rendered via lucide-react as SVG
expect(document.querySelector('svg')).toBeTruthy()
})
it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => {
render( )
- expect(screen.queryByText('Tag verknüpfen')).not.toBeInTheDocument()
- expect(screen.queryByPlaceholderText('Optionale Beschriftung...')).not.toBeInTheDocument()
+ expect(screen.queryByText('Link Day')).not.toBeInTheDocument()
+ expect(screen.queryByPlaceholderText('Optional caption...')).not.toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => {
@@ -65,27 +65,27 @@ describe('PhotoUpload', () => {
render( )
await uploadFiles([makeFile()])
expect(screen.getByAltText('photo.jpg')).toBeInTheDocument()
- expect(screen.getByText('Tag verknüpfen')).toBeInTheDocument()
- expect(screen.getByPlaceholderText('Optionale Beschriftung...')).toBeInTheDocument()
+ expect(screen.getByText('Link Day')).toBeInTheDocument()
+ expect(screen.getByPlaceholderText('Optional caption...')).toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => {
render( )
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
- expect(screen.getByText('2 Fotos ausgewählt')).toBeInTheDocument()
+ expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-006: remove button removes a file from preview', async () => {
render( )
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
- expect(screen.getByText('2 Fotos ausgewählt')).toBeInTheDocument()
+ expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
// Remove buttons are inside `.relative.aspect-square` wrappers in the preview grid
const removeButtons = document.querySelectorAll('.relative.aspect-square button')
expect(removeButtons.length).toBe(2)
await userEvent.click(removeButtons[0])
- expect(screen.getByText('1 Foto ausgewählt')).toBeInTheDocument()
+ expect(screen.getByText('1 Photo selected')).toBeInTheDocument()
expect(screen.getAllByRole('img').length).toBe(1)
})
@@ -120,7 +120,7 @@ describe('PhotoUpload', () => {
render( )
await uploadFiles([makeFile()])
- await userEvent.type(screen.getByPlaceholderText('Optionale Beschriftung...'), 'Vacation')
+ await userEvent.type(screen.getByPlaceholderText('Optional caption...'), 'Vacation')
await userEvent.click(getSubmitButton())
@@ -146,7 +146,7 @@ describe('PhotoUpload', () => {
await userEvent.click(getSubmitButton())
await waitFor(() => {
- expect(screen.getByText(/wird hochgeladen/i)).toBeInTheDocument()
+ expect(screen.getAllByText(/uploading/i).length).toBeGreaterThan(0)
})
expect(getSubmitButton()).toBeDisabled()
diff --git a/client/src/components/Photos/PhotoUpload.tsx b/client/src/components/Photos/PhotoUpload.tsx
index aee2e9fc..e3c6408d 100644
--- a/client/src/components/Photos/PhotoUpload.tsx
+++ b/client/src/components/Photos/PhotoUpload.tsx
@@ -85,12 +85,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
{isDragActive ? (
- Fotos hier ablegen...
+ {t('photos.dropHere')}
) : (
<>
- Fotos hier ablegen
+ {t('photos.dropHereActive')}
{t('photos.clickToSelect')}
- JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos
+ {t('photos.fileTypeHint')}
>
)}
@@ -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 && (
- Tag verknüpfen
+ {t('photos.linkDay')}
setDayId(e.target.value)}
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"
>
- Kein Tag
+ {t('photos.noDay')}
{(days || []).map(day => (
- Tag {day.day_number}
+ {t('photos.dayLabel', { number: day.day_number })}
))}
@@ -152,12 +152,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
- Beschriftung (für alle)
+ {t('photos.captionForAll')}
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}
}
-
{ e.stopPropagation(); toggleCollapse() }} title={collapsed ? 'Expand' : 'Collapse'}
+ { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, transition: 'all 0.15s ease' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 03520021..ca6abffb 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
-import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react'
+import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -55,6 +55,99 @@ const TYPE_ICONS = {
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
}
+function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onAddNew }: {
+ dayId: number
+ places: Place[]
+ assignments: AssignmentsMap
+ onAssign?: (placeId: number, dayId: number) => void
+ onAddNew?: () => void
+}) {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState('')
+
+ // Find places not assigned to this day
+ const assignedToDay = new Set((assignments[String(dayId)] || []).map(a => a.place_id))
+ const available = places.filter(p => !assignedToDay.has(p.id))
+ const filtered = search.trim()
+ ? available.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
+ : available
+
+ return (
+
+ {!open ? (
+
{ e.stopPropagation(); setOpen(true) }}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ padding: '10px 0', borderRadius: 12,
+ border: '1.5px dashed var(--border-primary)',
+ background: 'transparent', color: 'var(--text-muted)',
+ fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
+ }}
+ >
+
+ Add Place
+
+ ) : (
+
+
+ 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)' }}
+ />
+ { setOpen(false); setSearch('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}>
+
+
+
+
+ {filtered.length === 0 && (
+
+ {available.length === 0 ? t('dayplan.mobile.allAssigned') : t('dayplan.mobile.noMatch')}
+
+ )}
+ {filtered.slice(0, 20).map(p => (
+
{
+ onAssign?.(p.id, dayId)
+ setOpen(false)
+ setSearch('')
+ }}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', gap: 8,
+ padding: '10px 12px', border: 'none', background: 'transparent',
+ cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
+ }}
+ >
+
+ {p.name}
+
+ ))}
+
+ {onAddNew && (
+
{ onAddNew(); setOpen(false); setSearch('') }}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
+ padding: '10px 0', borderTop: '1px solid var(--border-faint)',
+ background: 'transparent', border: 'none', color: 'var(--text-muted)',
+ fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
+ }}
+ >
+
+ Create new place
+
+ )}
+
+ )}
+
+ )
+}
+
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}
+
+ )}
+
+
+
+ {t('common.cancel')}
+
+
+ {loading ? t('common.loading') : t('common.import')}
+
+
+
+
,
+ 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"
- />
-
- {isSearchingMaps ? '...' : }
-
+
+
+ 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"
+ />
+ { setAcSuggestions([]); handleMapsSearch() }}
+ disabled={isSearchingMaps}
+ className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
+ >
+ {isSearchingMaps ? '...' : }
+
+
+
+ {/* Autocomplete dropdown */}
+ {acSuggestions.length > 0 && (
+
+ {acSuggestions.map((s, idx) => (
+
handleSelectSuggestion(s)}
+ onMouseEnter={() => setAcHighlight(idx)}
+ className={`w-full text-left px-3 py-2 border-b border-slate-100 last:border-0 ${
+ idx === acHighlight ? 'bg-slate-100' : 'hover:bg-slate-50'
+ }`}
+ >
+ {s.mainText}
+ {s.secondaryText && (
+ {s.secondaryText}
+ )}
+
+ ))}
+
+ )}
+
+ {/* Search results (populated after full search) */}
{mapsResults.length > 0 && (
{mapsResults.map((result, idx) => (
@@ -267,14 +439,21 @@ export default function PlaceFormModal({
{/* Name */}
{t('places.formName')} *
-
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 */}
+
+ {t('places.formNotes')}
+
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx
index 13deb63a..be1da19b 100644
--- a/client/src/components/Planner/PlaceInspector.tsx
+++ b/client/src/components/Planner/PlaceInspector.tsx
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
-import { getAuthUrl } from '../../api/authUrl'
+import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
@@ -341,9 +341,16 @@ export default function PlaceInspector({
)}
{/* Description / Summary */}
- {(place.description || place.notes || googleDetails?.summary) && (
+ {(place.description || googleDetails?.summary) && (
- {place.description || place.notes || googleDetails?.summary || ''}
+ {place.description || googleDetails?.summary || ''}
+
+ )}
+
+ {/* Notes */}
+ {place.notes && (
+
+ {place.notes}
)}
@@ -582,7 +589,7 @@ export default function PlaceInspector({
{filesExpanded && placeFiles.length > 0 && (
{placeFiles.map(f => (
-
{ const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
+ openFile(f.url).catch(() => {})} style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer', background: 'none', border: 'none', width: '100%', textAlign: 'left' }}>
{(f.mime_type || '').startsWith('image/') ? : }
{f.original_name}
{f.file_size && {formatFileSize(f.file_size)} }
@@ -601,7 +608,7 @@ export default function PlaceInspector({
{selectedDayId && (
assignmentInDay ? (
onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={ }
- label={<>{t('inspector.removeFromDay')} Remove >} />
+ label={<>{t('inspector.removeFromDay')} {t('inspector.remove')} >} />
) : (
onAssignToDay(place.id)} variant="primary" icon={ } label={t('inspector.addToDay')} />
)
@@ -611,7 +618,7 @@ export default function PlaceInspector({
label={{t('inspector.google')} } />
)}
{!googleDetails?.google_maps_url && place.lat && place.lng && (
- window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={ }
+ 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')} variant="ghost" icon={ }
label={Google Maps } />
)}
{(place.website || googleDetails?.website) && (
diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx
index ba1557e6..dc25a418 100644
--- a/client/src/components/Planner/PlacesSidebar.test.tsx
+++ b/client/src/components/Planner/PlacesSidebar.test.tsx
@@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore';
+import { placesApi } from '../../api/client';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
@@ -432,32 +433,29 @@ describe('Mobile day-picker (portal)', () => {
// ── GPX import ────────────────────────────────────────────────────────────────
describe('GPX import', () => {
- it('FE-PLANNER-SIDEBAR-038: GPX import button triggers file input click', async () => {
+ it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => {
const user = userEvent.setup();
render( );
- const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
- expect(fileInput).toBeTruthy();
- const clickSpy = vi.spyOn(fileInput, 'click');
- await user.click(screen.getByText(/GPX/i));
- expect(clickSpy).toHaveBeenCalled();
+ await user.click(screen.getByText(/Import file/i));
+ expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument();
});
- it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => {
- server.use(
- http.post('/api/trips/1/places/import/gpx', () =>
- HttpResponse.json({ count: 2, places: [{ id: 10 }, { id: 11 }] })
- ),
- );
+ it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => {
+ const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
const loadTrip = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadTrip });
const addToast = vi.fn();
(window as any).__addToast = addToast;
+ const user = userEvent.setup();
render( );
- const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
+ await user.click(screen.getByText(/Import file/i));
+ const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement;
+ expect(fileInput).toBeTruthy();
const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' });
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});
+ await user.click(screen.getByRole('button', { name: /^import$/i }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining('2'),
@@ -465,6 +463,7 @@ describe('GPX import', () => {
undefined,
);
});
+ importSpy.mockRestore();
});
});
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index f2ea45ba..79f27b10 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -1,18 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom'
-import { useState, useRef, useMemo, useCallback } from 'react'
-import DOM from 'react-dom'
+import { useState, useMemo, useEffect, useRef } from 'react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
-import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
+import { useAddonStore } from '../../store/addonStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
+import FileImportModal from './FileImportModal'
interface PlacesSidebarProps {
tripId: number
@@ -28,7 +28,7 @@ interface PlacesSidebarProps {
onDeletePlace: (placeId: number) => void
days: Day[]
isMobile: boolean
- onCategoryFilterChange?: (categoryId: string) => void
+ onCategoryFilterChange?: (categoryIds: Set) => void
onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise | void) => void
}
@@ -40,50 +40,77 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const { t } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu()
- const gpxInputRef = useRef(null)
const trip = useTripStore((s) => s.trip)
const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
+ const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import'))
- const handleGpxImport = async (e: React.ChangeEvent) => {
- const file = e.target.files?.[0]
- if (!file) return
- e.target.value = ''
- try {
- const result = await placesApi.importGpx(tripId, file)
- await loadTrip(tripId)
- 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)
- })
- }
- } catch (err: any) {
- toast.error(err?.response?.data?.error || t('places.gpxError'))
- }
+ const [fileImportOpen, setFileImportOpen] = useState(false)
+ const [sidebarDropFile, setSidebarDropFile] = useState(null)
+ const [sidebarDragOver, setSidebarDragOver] = useState(false)
+ const sidebarDragCounter = useRef(0)
+
+ const handleSidebarDragEnter = (e: React.DragEvent) => {
+ if (!canEditPlaces) return
+ e.preventDefault()
+ sidebarDragCounter.current++
+ setSidebarDragOver(true)
}
- const [googleListOpen, setGoogleListOpen] = useState(false)
- const [googleListUrl, setGoogleListUrl] = useState('')
- const [googleListLoading, setGoogleListLoading] = useState(false)
+ const handleSidebarDragOver = (e: React.DragEvent) => {
+ if (!canEditPlaces) return
+ e.preventDefault()
+ }
- const handleGoogleListImport = async () => {
- if (!googleListUrl.trim()) return
- setGoogleListLoading(true)
+ const handleSidebarDragLeave = () => {
+ sidebarDragCounter.current--
+ if (sidebarDragCounter.current === 0) setSidebarDragOver(false)
+ }
+
+ const handleSidebarDrop = (e: React.DragEvent) => {
+ e.preventDefault()
+ sidebarDragCounter.current = 0
+ setSidebarDragOver(false)
+ if (!canEditPlaces) return
+ const f = e.dataTransfer.files[0]
+ if (!f) return
+ setSidebarDropFile(f)
+ setFileImportOpen(true)
+ }
+
+ const [listImportOpen, setListImportOpen] = useState(false)
+ const [listImportUrl, setListImportUrl] = useState('')
+ const [listImportLoading, setListImportLoading] = useState(false)
+ const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
+ const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
+ const hasMultipleListImportProviders = availableListImportProviders.length > 1
+
+ useEffect(() => {
+ if (!isNaverListImportEnabled && listImportProvider === 'naver') {
+ setListImportProvider('google')
+ }
+ }, [isNaverListImportEnabled, listImportProvider])
+
+ const handleListImport = async () => {
+ if (!listImportUrl.trim()) return
+ setListImportLoading(true)
+ const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
try {
- const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
+ const result = provider === 'google'
+ ? await placesApi.importGoogleList(tripId, listImportUrl.trim())
+ : await placesApi.importNaverList(tripId, listImportUrl.trim())
await loadTrip(tripId)
- toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
- setGoogleListOpen(false)
- setGoogleListUrl('')
+ if (result.count === 0 && result.skipped > 0) {
+ toast.warning(t('places.importAllSkipped'))
+ } else {
+ toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
+ }
+ setListImportOpen(false)
+ setListImportUrl('')
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
- pushUndo?.(t('undo.importGoogleList'), async () => {
+ pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
@@ -91,9 +118,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
})
}
} catch (err: any) {
- toast.error(err?.response?.data?.error || t('places.googleListError'))
+ toast.error(err?.response?.data?.error || t(provider === 'google' ? 'places.googleListError' : 'places.naverListError'))
} finally {
- setGoogleListLoading(false)
+ setListImportLoading(false)
}
}
@@ -105,8 +132,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
setCategoryFiltersLocal(prev => {
const next = new Set(prev)
if (next.has(catId)) next.delete(catId); else next.add(catId)
- // Notify parent with first selected or empty
- onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
+ onCategoryFilterChange?.(next)
return next
})
}
@@ -131,7 +157,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
return (
-
+
+ {sidebarDragOver && (
+
+
+ {t('places.sidebarDrop')}
+
+ )}
{/* Kopfbereich */}
{canEditPlaces &&
{t('places.addPlace')}
}
{canEditPlaces && <>
-
gpxInputRef.current?.click()}
+ onClick={() => setFileImportOpen(true)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
@@ -158,10 +202,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
cursor: 'pointer', fontFamily: 'inherit',
}}
>
- {t('places.importGpx')}
+ {t('places.importFile')}
setGoogleListOpen(true)}
+ onClick={() => setListImportOpen(true)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
@@ -170,7 +214,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
cursor: 'pointer', fontFamily: 'inherit',
}}
>
- {t('places.importGoogleList')}
+ {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
>}
@@ -257,7 +301,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
)
})}
{categoryFilters.size > 0 && (
-
{ setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
+ { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.(new Set()) }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
@@ -317,7 +361,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
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 },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])}
@@ -381,7 +425,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
>
e.stopPropagation()}
- style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
+ style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'var(--bottom-nav-h)' }}
>
{dayPickerPlace.name}
@@ -448,9 +492,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
,
document.body
)}
- {googleListOpen && ReactDOM.createPortal(
+ {listImportOpen && ReactDOM.createPortal(
{ setGoogleListOpen(false); setGoogleListUrl('') }}
+ onClick={() => { setListImportOpen(false); setListImportUrl('') }}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
- {t('places.importGoogleList')}
+ {t('places.importList')}
+ {hasMultipleListImportProviders && (
+
+ {availableListImportProviders.map(provider => (
+ setListImportProvider(provider)}
+ style={{
+ padding: '6px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
+ fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
+ background: listImportProvider === provider ? 'var(--accent)' : 'var(--bg-tertiary)',
+ color: listImportProvider === provider ? 'var(--accent-text)' : 'var(--text-muted)',
+ }}
+ >
+ {provider === 'google' ? t('places.importGoogleList') : t('places.importNaverList')}
+
+ ))}
+
+ )}
- {t('places.googleListHint')}
+ {t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
setGoogleListUrl(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
- placeholder="https://maps.app.goo.gl/..."
+ value={listImportUrl}
+ onChange={e => setListImportUrl(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter' && !listImportLoading) handleListImport() }}
+ placeholder={listImportProvider === 'google' ? 'https://maps.app.goo.gl/...' : 'https://naver.me/...'}
autoFocus
style={{
width: '100%', padding: '10px 14px', borderRadius: 10,
@@ -479,7 +541,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
/>
{ setGoogleListOpen(false); setGoogleListUrl('') }}
+ onClick={() => { setListImportOpen(false); setListImportUrl('') }}
style={{
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
@@ -489,23 +551,30 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{t('common.cancel')}
- {googleListLoading ? t('common.loading') : t('common.import')}
+ {listImportLoading ? t('common.loading') : t('common.import')}
,
document.body
)}
+
{ setFileImportOpen(false); setSidebarDropFile(null) }}
+ tripId={tripId}
+ pushUndo={pushUndo}
+ initialFile={sidebarDropFile}
+ />
)
diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx
index ad17acd6..a70924ed 100644
--- a/client/src/components/Planner/ReservationModal.tsx
+++ b/client/src/components/Planner/ReservationModal.tsx
@@ -10,7 +10,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
-import { getAuthUrl } from '../../api/authUrl'
+import { openFile } from '../../utils/fileDownload'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [
@@ -587,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{f.original_name}
-
{ e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}>
+
{ e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}>
{
// Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 030385e0..95942a9a 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -10,7 +10,7 @@ import {
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react'
-import { getAuthUrl } from '../../api/authUrl'
+import { openFile } from '../../utils/fileDownload'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry {
@@ -253,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('files.title')}
{attachedFiles.map(f => (
-
{ e.preventDefault(); const u = await getAuthUrl(f.url, 'download'); window.open(u, '_blank', 'noreferrer') }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
+ { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
{f.original_name}
diff --git a/client/src/components/Settings/AccountTab.tsx b/client/src/components/Settings/AccountTab.tsx
index 1c9abfdc..a051ed74 100644
--- a/client/src/components/Settings/AccountTab.tsx
+++ b/client/src/components/Settings/AccountTab.tsx
@@ -142,7 +142,7 @@ export default function AccountTab(): React.ReactElement {
await updateProfile({ username, email })
toast.success(t('settings.toast.profileSaved'))
} catch (err: unknown) {
- toast.error(err instanceof Error ? err.message : 'Error')
+ toast.error(err instanceof Error ? err.message : t('common.error'))
} finally {
setSaving(false)
}
diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx
index e09b45de..ed066701 100644
--- a/client/src/components/Settings/DisplaySettingsTab.tsx
+++ b/client/src/components/Settings/DisplaySettingsTab.tsx
@@ -34,7 +34,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
onClick={async () => {
try {
await updateSetting('dark_mode', opt.value)
- } catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
+ } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 6,
@@ -63,7 +63,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={opt.value}
onClick={async () => {
try { await updateSetting('language', opt.value) }
- catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -94,7 +94,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
onClick={async () => {
setTempUnit(opt.value)
try { await updateSetting('temperature_unit', opt.value) }
- catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -124,7 +124,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={opt.value}
onClick={async () => {
try { await updateSetting('time_format', opt.value) }
- catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -154,7 +154,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('route_calculation', opt.value) }
- catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
@@ -184,7 +184,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('blur_booking_codes', opt.value) }
- catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
diff --git a/client/src/components/Settings/IntegrationsTab.test.tsx b/client/src/components/Settings/IntegrationsTab.test.tsx
index 84eeb161..7170da6e 100644
--- a/client/src/components/Settings/IntegrationsTab.test.tsx
+++ b/client/src/components/Settings/IntegrationsTab.test.tsx
@@ -1,4 +1,4 @@
-// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-018
+// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-032
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
+import { ToastContainer } from '../shared/Toast';
import IntegrationsTab from './IntegrationsTab';
function enableMcp() {
@@ -40,6 +41,8 @@ beforeEach(() => {
server.use(
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
+ http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
+ http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
);
});
@@ -69,18 +72,26 @@ describe('IntegrationsTab', () => {
expect(codeEl!.textContent).toContain('/mcp');
});
- it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered', async () => {
+ it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered when expanded', async () => {
+ const user = userEvent.setup();
enableMcp();
render(
);
await screen.findByText('MCP Configuration');
+ // Config is collapsed by default — no
yet
+ expect(document.querySelector('pre')).toBeNull();
+ // Expand by clicking the "Client Configuration" toggle
+ await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
const preEl = document.querySelector('pre');
expect(preEl).not.toBeNull();
expect(preEl!.textContent).toContain('mcpServers');
});
it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => {
+ const user = userEvent.setup();
enableMcp();
render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('No tokens yet. Create one to connect MCP clients.');
});
@@ -95,8 +106,11 @@ describe('IntegrationsTab', () => {
}),
),
);
+ const user = userEvent.setup();
enableMcp();
render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('My Token');
await screen.findByText('Other Token');
});
@@ -106,6 +120,7 @@ describe('IntegrationsTab', () => {
enableMcp();
render( );
await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
const createBtn = screen.getByRole('button', { name: /Create New Token/i });
await user.click(createBtn);
await screen.findByText('Create API Token');
@@ -116,6 +131,7 @@ describe('IntegrationsTab', () => {
enableMcp();
render( );
await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
@@ -127,6 +143,7 @@ describe('IntegrationsTab', () => {
enableMcp();
render( );
await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -153,6 +170,7 @@ describe('IntegrationsTab', () => {
enableMcp();
render( );
await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -182,6 +200,7 @@ describe('IntegrationsTab', () => {
enableMcp();
render( );
await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test');
@@ -206,6 +225,8 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token'));
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
@@ -230,6 +251,8 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token'));
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal
@@ -289,6 +312,8 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup();
enableMcp();
render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Cancel Token');
await user.click(screen.getByTitle('Delete Token'));
await screen.findByRole('button', { name: /^Cancel$/i });
@@ -319,6 +344,7 @@ describe('IntegrationsTab', () => {
enableMcp();
render( );
await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -328,4 +354,301 @@ describe('IntegrationsTab', () => {
expect(postCalled).toBe(true);
});
});
+
+ it('FE-COMP-INTEGRATIONS-019: default tab is OAuth 2.1 Clients — OAuth hint visible, token list hidden', async () => {
+ enableMcp();
+ render( );
+ await screen.findByText('MCP Configuration');
+ // OAuth hint is visible on the default tab
+ expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
+ // API Tokens "no tokens" message is not rendered
+ expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
+ });
+
+ it('FE-COMP-INTEGRATIONS-020: switching tabs toggles content visibility', async () => {
+ const user = userEvent.setup();
+ enableMcp();
+ render( );
+ await screen.findByText('MCP Configuration');
+ // Default: OAuth hint visible, token list absent
+ expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
+ expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
+ // Switch to API Tokens tab
+ await user.click(screen.getByRole('button', { name: /API Tokens/i }));
+ await screen.findByText('No tokens yet. Create one to connect MCP clients.');
+ expect(screen.queryByText(/Register OAuth 2\.1 clients/i)).toBeNull();
+ // Switch back to OAuth tab
+ await user.click(screen.getByRole('button', { name: /OAuth 2\.1 Clients/i }));
+ await screen.findByText(/Register OAuth 2\.1 clients/i);
+ expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
+ });
+
+ it('FE-COMP-INTEGRATIONS-021: OAuth client list renders when clients exist', async () => {
+ server.use(
+ http.get('/api/oauth/clients', () =>
+ HttpResponse.json({
+ clients: [
+ {
+ id: 'client-1',
+ client_id: 'clid-abc',
+ name: 'My OAuth App',
+ redirect_uris: ['http://localhost'],
+ allowed_scopes: ['trips:read', 'places:read'],
+ created_at: '2025-01-01T00:00:00Z',
+ },
+ ],
+ })
+ )
+ );
+ enableMcp();
+ render( );
+ await screen.findByText('My OAuth App');
+ expect(screen.getByText(/clid-abc/)).toBeInTheDocument();
+ });
+
+ it('FE-COMP-INTEGRATIONS-022: scope expansion toggle shows more/fewer scopes', async () => {
+ const user = userEvent.setup();
+ const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
+ server.use(
+ http.get('/api/oauth/clients', () =>
+ HttpResponse.json({
+ clients: [
+ { id: 'c1', client_id: 'cid', name: 'Big App', redirect_uris: ['http://localhost'], allowed_scopes: scopes, created_at: '2025-01-01T00:00:00Z' },
+ ],
+ })
+ )
+ );
+ enableMcp();
+ render( );
+ await screen.findByText('Big App');
+ // "+2 more" button visible (7 scopes, 5 shown)
+ const moreBtn = screen.getByText(/^\+\d+$/);
+ await user.click(moreBtn);
+ // Show less / collapse button now visible
+ expect(screen.getByText('−')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-INTEGRATIONS-023: active OAuth sessions section renders when sessions exist', async () => {
+ server.use(
+ http.get('/api/oauth/sessions', () =>
+ HttpResponse.json({
+ sessions: [
+ {
+ id: 10,
+ client_name: 'Claude Desktop',
+ scopes: ['trips:read'],
+ access_token_expires_at: '2025-12-31T00:00:00Z',
+ },
+ ],
+ })
+ )
+ );
+ enableMcp();
+ render( );
+ await screen.findByText('Claude Desktop');
+ expect(screen.getByText(/trips:read/)).toBeInTheDocument();
+ });
+
+ it('FE-COMP-INTEGRATIONS-024: Create OAuth Client modal opens and shows presets', async () => {
+ const user = userEvent.setup();
+ enableMcp();
+ render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /New Client/i }));
+ await screen.findByText('Register OAuth Client');
+ expect(screen.getByText('Claude.ai')).toBeInTheDocument();
+ expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
+ });
+
+ it('FE-COMP-INTEGRATIONS-025: clicking a preset fills form fields', async () => {
+ const user = userEvent.setup();
+ enableMcp();
+ render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /New Client/i }));
+ await screen.findByText('Register OAuth Client');
+ // Presets render as buttons — click "Claude.ai" preset
+ const presetBtns = screen.getAllByRole('button', { name: /Claude\.ai/i });
+ await user.click(presetBtns[0]);
+ // Name field should be filled with 'Claude.ai'
+ const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
+ expect((nameInput as HTMLInputElement).value).toBe('Claude.ai');
+ });
+
+ it('FE-COMP-INTEGRATIONS-026: creating client shows success view with client_id and secret', async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.post('/api/oauth/clients', () =>
+ HttpResponse.json({
+ client: {
+ id: 'new-id',
+ client_id: 'clid-new',
+ client_secret: 'secret-value',
+ name: 'Test Client',
+ redirect_uris: ['http://localhost'],
+ allowed_scopes: ['trips:read'],
+ created_at: '2025-01-01T00:00:00Z',
+ },
+ })
+ )
+ );
+ enableMcp();
+ render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /New Client/i }));
+ await screen.findByText('Register OAuth Client');
+
+ const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
+ await user.type(nameInput, 'Test Client');
+ const uriInput = screen.getByPlaceholderText(/https:\/\/your-app/i);
+ await user.type(uriInput, 'http://localhost');
+ await user.click(screen.getByRole('button', { name: /Register Client/i }));
+ // Success view shows client credentials (there may be multiple matches in list + modal)
+ await screen.findAllByText(/clid-new/);
+ const secretEls = await screen.findAllByText(/secret-value/);
+ expect(secretEls.length).toBeGreaterThan(0);
+ });
+
+ it('FE-COMP-INTEGRATIONS-027: Done button closes created-client modal', async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.post('/api/oauth/clients', () =>
+ HttpResponse.json({
+ client: {
+ id: 'n2',
+ client_id: 'clid-n2',
+ client_secret: 'secret-n2',
+ name: 'TC2',
+ redirect_uris: ['http://localhost'],
+ allowed_scopes: ['trips:read'],
+ created_at: '2025-01-01T00:00:00Z',
+ },
+ })
+ )
+ );
+ enableMcp();
+ render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /New Client/i }));
+ await screen.findByText('Register OAuth Client');
+ await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'TC2');
+ await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
+ await user.click(screen.getByRole('button', { name: /Register Client/i }));
+ await screen.findAllByText(/clid-n2/);
+ // Check the "Client Registered" modal title is visible before Done
+ expect(screen.getByText('Client Registered')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: /^Done$/i }));
+ await waitFor(() => {
+ expect(screen.queryByText('Client Registered')).toBeNull();
+ });
+ });
+
+ it('FE-COMP-INTEGRATIONS-028: delete OAuth client confirmation removes client from list', async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.get('/api/oauth/clients', () =>
+ HttpResponse.json({
+ clients: [
+ { id: 'del-1', client_id: 'cid-del', name: 'Delete Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
+ ],
+ })
+ ),
+ http.delete('/api/oauth/clients/del-1', () => HttpResponse.json({ success: true }))
+ );
+ enableMcp();
+ render(<> >);
+ await screen.findByText('Delete Me');
+ await user.click(screen.getByTitle('Delete Client'));
+ // Confirmation modal
+ await screen.findByRole('heading', { name: 'Delete Client' });
+ const confirmBtns = screen.getAllByRole('button', { name: /Delete Client/i });
+ // Modal confirm button is last in DOM (modal renders after list)
+ await user.click(confirmBtns[confirmBtns.length - 1]);
+ await waitFor(() => {
+ expect(screen.queryByText('Delete Me')).toBeNull();
+ });
+ });
+
+ it('FE-COMP-INTEGRATIONS-029: rotate secret confirmation shows new secret', async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.get('/api/oauth/clients', () =>
+ HttpResponse.json({
+ clients: [
+ { id: 'rot-1', client_id: 'cid-rot', name: 'Rotate Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
+ ],
+ })
+ ),
+ http.post('/api/oauth/clients/rot-1/rotate', () =>
+ HttpResponse.json({ client_secret: 'new-rotated-secret' })
+ )
+ );
+ enableMcp();
+ render( );
+ await screen.findByText('Rotate Me');
+ await user.click(screen.getByTitle('Rotate Secret'));
+ await screen.findByText('Rotate Secret');
+ // Confirm — button text is 'Rotate'
+ const rotateBtns = screen.getAllByRole('button', { name: /^Rotate$/i });
+ await user.click(rotateBtns[rotateBtns.length - 1]);
+ await screen.findByText(/new-rotated-secret/);
+ });
+
+ it('FE-COMP-INTEGRATIONS-030: revoke OAuth session removes it from list', async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.get('/api/oauth/sessions', () =>
+ HttpResponse.json({
+ sessions: [
+ { id: 99, client_name: 'Revoke App', scopes: ['trips:read'], access_token_expires_at: '2025-12-31T00:00:00Z' },
+ ],
+ })
+ ),
+ http.delete('/api/oauth/sessions/99', () => HttpResponse.json({ success: true }))
+ );
+ enableMcp();
+ render(<> >);
+ await screen.findByText('Revoke App');
+ await user.click(screen.getByText('Revoke'));
+ // Confirmation modal
+ await screen.findByText('Revoke Session');
+ const revokeBtns = screen.getAllByRole('button', { name: /^Revoke$/i });
+ // Modal confirm button is last in DOM
+ await user.click(revokeBtns[revokeBtns.length - 1]);
+ await waitFor(() => {
+ expect(screen.queryByText('Revoke App')).toBeNull();
+ });
+ });
+
+ it('FE-COMP-INTEGRATIONS-031: Register Client button disabled when name or URI is empty', async () => {
+ const user = userEvent.setup();
+ enableMcp();
+ render( );
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /New Client/i }));
+ await screen.findByText('Register OAuth Client');
+ const createBtn = screen.getByRole('button', { name: /Register Client/i });
+ expect(createBtn).toBeDisabled();
+ // Type only name, not URI → still disabled
+ await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Test');
+ expect(createBtn).toBeDisabled();
+ });
+
+ it('FE-COMP-INTEGRATIONS-032: error toast shown when create OAuth client fails', async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.post('/api/oauth/clients', () =>
+ HttpResponse.json({ error: 'server error' }, { status: 500 })
+ )
+ );
+ enableMcp();
+ render(<> >);
+ await screen.findByText('MCP Configuration');
+ await user.click(screen.getByRole('button', { name: /New Client/i }));
+ await screen.findByText('Register OAuth Client');
+ await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
+ await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
+ await user.click(screen.getByRole('button', { name: /Register Client/i }));
+ await screen.findByText(/Failed to register/i);
+ });
});
diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx
index f56dbcdf..430da0f6 100644
--- a/client/src/components/Settings/IntegrationsTab.tsx
+++ b/client/src/components/Settings/IntegrationsTab.tsx
@@ -1,12 +1,87 @@
import Section from './Section'
-import React, { useEffect, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
-import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react'
-import { authApi } from '../../api/client'
+import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
+import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection'
+import { ALL_SCOPES } from '../../api/oauthScopes'
+import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
+interface OAuthPreset {
+ id: string
+ label: string
+ name: string
+ uris: string
+ scopes: string[]
+}
+
+const OAUTH_PRESETS: OAuthPreset[] = [
+ {
+ id: 'claude-web',
+ label: 'Claude.ai',
+ name: 'Claude.ai',
+ uris: 'https://claude.ai/api/mcp/auth_callback',
+ scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
+ },
+ {
+ id: 'claude-desktop',
+ label: 'Claude Desktop',
+ name: 'Claude Desktop',
+ uris: 'http://localhost',
+ scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
+ },
+ {
+ id: 'cursor',
+ label: 'Cursor',
+ name: 'Cursor',
+ uris: 'http://localhost',
+ scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
+ },
+ {
+ id: 'vscode',
+ label: 'VS Code',
+ name: 'VS Code / Copilot',
+ uris: 'http://localhost',
+ scopes: ALL_SCOPES.filter(s => s.endsWith(':read')),
+ },
+ {
+ id: 'windsurf',
+ label: 'Windsurf',
+ name: 'Windsurf',
+ uris: 'http://localhost',
+ scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
+ },
+ {
+ id: 'zed',
+ label: 'Zed',
+ name: 'Zed',
+ uris: 'http://localhost',
+ scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
+ },
+]
+
+
+interface OAuthClient {
+ id: string
+ name: string
+ client_id: string
+ redirect_uris: string[]
+ allowed_scopes: string[]
+ created_at: string
+ client_secret?: string // only present on create
+}
+
+interface OAuthSession {
+ id: number
+ client_id: string
+ client_name: string
+ scopes: string[]
+ access_token_expires_at: string
+ refresh_token_expires_at: string
+ created_at: string
+}
interface McpToken {
id: number
@@ -26,6 +101,28 @@ export default function IntegrationsTab(): React.ReactElement {
loadAddons()
}, [loadAddons])
+ // OAuth clients state
+ const [oauthClients, setOauthClients] = useState([])
+ const [oauthSessions, setOauthSessions] = useState([])
+ const [oauthCreateOpen, setOauthCreateOpen] = useState(false)
+ const [oauthNewName, setOauthNewName] = useState('')
+ const [oauthNewUris, setOauthNewUris] = useState('')
+ const [oauthNewScopes, setOauthNewScopes] = useState([])
+ const [oauthCreating, setOauthCreating] = useState(false)
+ const [oauthCreatedClient, setOauthCreatedClient] = useState(null)
+ const [oauthDeleteId, setOauthDeleteId] = useState(null)
+ const [oauthRevokeId, setOauthRevokeId] = useState(null)
+ const [oauthRotateId, setOauthRotateId] = useState(null)
+ const [oauthRotatedSecret, setOauthRotatedSecret] = useState(null)
+ const [oauthRotating, setOauthRotating] = useState(false)
+ // oauthScopesOpen is managed internally by ScopeGroupPicker
+ const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({})
+
+ // MCP sub-tab state
+ const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
+ const [configOpenOAuth, setConfigOpenOAuth] = useState(false)
+ const [configOpenToken, setConfigOpenToken] = useState(false)
+
// MCP state
const [mcpTokens, setMcpTokens] = useState([])
const [mcpModalOpen, setMcpModalOpen] = useState(false)
@@ -34,8 +131,26 @@ export default function IntegrationsTab(): React.ReactElement {
const [mcpCreating, setMcpCreating] = useState(false)
const [mcpDeleteId, setMcpDeleteId] = useState(null)
const [copiedKey, setCopiedKey] = useState(null)
+ const copyTimerRef = useRef | null>(null)
+
+ useEffect(() => {
+ return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
+ }, [])
const mcpEndpoint = `${window.location.origin}/mcp`
+ const mcpJsonConfigOAuth = `{
+ "mcpServers": {
+ "trek": {
+ "command": "npx",
+ "args": [
+ "mcp-remote",
+ "${mcpEndpoint}",
+ "--static-oauth-client-info",
+ "{\\"client_id\\": \\"\\", \\"client_secret\\": \\"\\"}"
+ ]
+ }
+ }
+}`
const mcpJsonConfig = `{
"mcpServers": {
"trek": {
@@ -85,10 +200,72 @@ export default function IntegrationsTab(): React.ReactElement {
const handleCopy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedKey(key)
- setTimeout(() => setCopiedKey(null), 2000)
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
+ copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000)
})
}
+ // Load OAuth clients and sessions
+ useEffect(() => {
+ if (mcpEnabled) {
+ oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {})
+ oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {})
+ }
+ }, [mcpEnabled])
+
+ const handleCreateOAuthClient = async () => {
+ if (!oauthNewName.trim() || !oauthNewUris.trim()) return
+ setOauthCreating(true)
+ try {
+ const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
+ const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
+ setOauthCreatedClient(d.client)
+ setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
+ setOauthNewName('')
+ setOauthNewUris('')
+ setOauthNewScopes([])
+ } catch {
+ toast.error(t('settings.oauth.toast.createError'))
+ } finally {
+ setOauthCreating(false)
+ }
+ }
+
+ const handleDeleteOAuthClient = async (id: string) => {
+ try {
+ await oauthApi.clients.delete(id)
+ setOauthClients(prev => prev.filter(c => c.id !== id))
+ setOauthDeleteId(null)
+ toast.success(t('settings.oauth.toast.deleted'))
+ } catch {
+ toast.error(t('settings.oauth.toast.deleteError'))
+ }
+ }
+
+ const handleRotateSecret = async (id: string) => {
+ setOauthRotating(true)
+ try {
+ const d = await oauthApi.clients.rotate(id)
+ setOauthRotatedSecret(d.client_secret)
+ setOauthRotateId(null)
+ } catch {
+ toast.error(t('settings.oauth.toast.rotateError'))
+ } finally {
+ setOauthRotating(false)
+ }
+ }
+
+ const handleRevokeSession = async (id: number) => {
+ try {
+ await oauthApi.sessions.revoke(id)
+ setOauthSessions(prev => prev.filter(s => s.id !== id))
+ setOauthRevokeId(null)
+ toast.success(t('settings.oauth.toast.revoked'))
+ } catch {
+ toast.error(t('settings.oauth.toast.revokeError'))
+ }
+ }
+
return (
<>
@@ -109,63 +286,217 @@ export default function IntegrationsTab(): React.ReactElement {
- {/* JSON config box */}
-
-
- {t('settings.mcp.clientConfig')}
- handleCopy(mcpJsonConfig, 'json')}
- className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
- style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
- {copiedKey === 'json' ? : }
- {copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
-
-
-
- {mcpJsonConfig}
-
-
{t('settings.mcp.clientConfigHint')}
+ {/* Sub-tab bar */}
+
+ setActiveMcpTab('oauth')}
+ className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
+ activeMcpTab === 'oauth' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
+ }`}>
+ {t('settings.oauth.clients')}
+
+ setActiveMcpTab('apitokens')}
+ className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2 ${
+ activeMcpTab === 'apitokens' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
+ }`}>
+ {t('settings.mcp.apiTokens')}
+
+ Deprecated
+
+
- {/* Token list */}
-
-
-
{t('settings.mcp.apiTokens')}
-
{ setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
- className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
- style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
- {t('settings.mcp.createToken')}
-
-
-
- {mcpTokens.length === 0 ? (
-
- {t('settings.mcp.noTokens')}
-
- ) : (
+ {/* OAuth 2.1 Clients tab */}
+ {activeMcpTab === 'oauth' && (
+ <>
+ {/* JSON config — OAuth (collapsible) */}
- {mcpTokens.map((token, i) => (
-
-
-
{token.name}
-
- {token.token_prefix}...
- {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}
- {token.last_used_at && (
- · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}
- )}
-
+
setConfigOpenOAuth(o => !o)}
+ className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
+ style={{ background: 'var(--bg-secondary)' }}>
+ {t('settings.mcp.clientConfig')}
+ {configOpenOAuth ? : }
+
+ {configOpenOAuth && (
+
+
+ handleCopy(mcpJsonConfigOAuth, 'json-oauth')}
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {copiedKey === 'json-oauth' ? : }
+ {copiedKey === 'json-oauth' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
+
-
setMcpDeleteId(token.id)}
- className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
- style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
-
-
+
+ {mcpJsonConfigOAuth}
+
+
{t('settings.mcp.clientConfigHintOAuth')}
- ))}
+ )}
- )}
-
+
+
+
{t('settings.oauth.clientsHint')}
+
+
+
{ setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
+ {t('settings.oauth.createClient')}
+
+
+
+ {oauthClients.length === 0 ? (
+
+ {t('settings.oauth.noClients')}
+
+ ) : (
+
+ {oauthClients.map((client, i) => (
+
+
+
+
+
{client.name}
+
+ {t('settings.oauth.clientId')}: {client.client_id}
+ {t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}
+
+
+ {(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
+ {s}
+ ))}
+ {client.allowed_scopes.length > 5 && (
+ setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
+ className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
+ {oauthScopesExpanded[client.id] ? '−' : `+${client.allowed_scopes.length - 5}`}
+
+ )}
+
+
+
setOauthRotateId(client.id)}
+ className="p-1.5 rounded-lg transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20"
+ style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.rotateSecret')}>
+
+
+
setOauthDeleteId(client.id)}
+ className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
+ style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.deleteClient')}>
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Active OAuth Sessions */}
+ {oauthSessions.length > 0 && (
+
+
{t('settings.oauth.activeSessions')}
+
+ {oauthSessions.map((session, i) => (
+
+
+
{session.client_name}
+
+ {t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
+ {t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}
+
+
+
setOauthRevokeId(session.id)}
+ className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
+ style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
+ {t('settings.oauth.revoke')}
+
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ {/* API Tokens tab (deprecated) */}
+ {activeMcpTab === 'apitokens' && (
+ <>
+
+
⚠
+
{t('settings.mcp.apiTokensDeprecated')}
+
+
+ {/* JSON config — API Token (collapsible) */}
+
+
setConfigOpenToken(o => !o)}
+ className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
+ style={{ background: 'var(--bg-secondary)' }}>
+ {t('settings.mcp.clientConfig')}
+ {configOpenToken ? : }
+
+ {configOpenToken && (
+
+
+ handleCopy(mcpJsonConfig, 'json-token')}
+ className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {copiedKey === 'json-token' ? : }
+ {copiedKey === 'json-token' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
+
+
+
+ {mcpJsonConfig}
+
+
{t('settings.mcp.clientConfigHint')}
+
+ )}
+
+
+
+
{ setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60"
+ style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}>
+ {t('settings.mcp.createToken')}
+
+
+
+ {mcpTokens.length === 0 ? (
+
+ {t('settings.mcp.noTokens')}
+
+ ) : (
+
+ {mcpTokens.map((token, i) => (
+
+
+
{token.name}
+
+ {token.token_prefix}...
+ {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}
+ {token.last_used_at && (
+ · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}
+ )}
+
+
+
setMcpDeleteId(token.id)}
+ className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
+ style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
+
+
+
+ ))}
+
+ )}
+ >
+ )}
)}
@@ -182,7 +513,7 @@ export default function IntegrationsTab(): React.ReactElement {
setMcpNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
- className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
+ className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus />
@@ -192,8 +523,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
@@ -217,8 +547,7 @@ export default function IntegrationsTab(): React.ReactElement {
{ setMcpModalOpen(false); setMcpCreatedToken(null) }}
- className="px-4 py-2 rounded-lg text-sm font-medium text-white"
- style={{ background: 'var(--accent-primary, #4f46e5)' }}>
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
{t('settings.mcp.modal.done')}
@@ -248,6 +577,216 @@ export default function IntegrationsTab(): React.ReactElement {
)}
+
+ {/* Create OAuth Client modal */}
+ {oauthCreateOpen && (
+
{ if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
+
+ {!oauthCreatedClient ? (
+ <>
+
{t('settings.oauth.modal.createTitle')}
+
+
+
{t('settings.oauth.modal.presets')}
+
+ {OAUTH_PRESETS.map(preset => (
+ {
+ setOauthNewName(preset.name)
+ setOauthNewUris(preset.uris)
+ setOauthNewScopes(preset.scopes)
+ }}
+ className="px-2.5 py-1 rounded-md text-xs font-medium border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-secondary)' }}>
+ {preset.label}
+
+ ))}
+
+
+
+
+ {t('settings.oauth.modal.clientName')}
+ setOauthNewName(e.target.value)}
+ placeholder={t('settings.oauth.modal.clientNamePlaceholder')}
+ className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
+ style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
+ autoFocus />
+
+
+
+
{t('settings.oauth.modal.redirectUris')}
+
+
+
+
{t('settings.oauth.modal.scopes')}
+
{t('settings.oauth.modal.scopesHint')}
+
+
+
+
+ setOauthCreateOpen(false)}
+ className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {t('common.cancel')}
+
+
+ {oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
+
+
+ >
+ ) : (
+ <>
+
{t('settings.oauth.modal.createdTitle')}
+
+
⚠
+
{t('settings.oauth.modal.createdWarning')}
+
+
+
+
+
{t('settings.oauth.clientId')}
+
+
+ {oauthCreatedClient.client_id}
+
+ handleCopy(oauthCreatedClient.client_id, 'new-client-id')}
+ className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)' }}>
+ {copiedKey === 'new-client-id' ? : }
+
+
+
+
+
{t('settings.oauth.clientSecret')}
+
+
+ {oauthCreatedClient.client_secret}
+
+ handleCopy(oauthCreatedClient.client_secret!, 'new-client-secret')}
+ className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)' }}>
+ {copiedKey === 'new-client-secret' ? : }
+
+
+
+
+
+
+ { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
+ {t('settings.mcp.modal.done')}
+
+
+ >
+ )}
+
+
+ )}
+
+ {/* Delete OAuth Client confirm */}
+ {oauthDeleteId !== null && (
+
{ if (e.target === e.currentTarget) setOauthDeleteId(null) }}>
+
+
{t('settings.oauth.deleteClient')}
+
{t('settings.oauth.deleteClientMessage')}
+
+ setOauthDeleteId(null)}
+ className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {t('common.cancel')}
+
+ handleDeleteOAuthClient(oauthDeleteId)}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
+ {t('settings.oauth.deleteClient')}
+
+
+
+
+ )}
+
+ {/* Rotate OAuth Client Secret confirm */}
+ {oauthRotateId !== null && (
+
{ if (e.target === e.currentTarget) setOauthRotateId(null) }}>
+
+
{t('settings.oauth.rotateSecret')}
+
{t('settings.oauth.rotateSecretMessage')}
+
+ setOauthRotateId(null)}
+ className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {t('common.cancel')}
+
+ handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
+ {oauthRotating ? t('settings.oauth.rotateSecretConfirming') : t('settings.oauth.rotateSecretConfirm')}
+
+
+
+
+ )}
+
+ {/* Rotated Secret display */}
+ {oauthRotatedSecret !== null && (
+
+
+
{t('settings.oauth.rotateSecretDoneTitle')}
+
+
⚠
+
{t('settings.oauth.rotateSecretDoneWarning')}
+
+
+
{t('settings.oauth.clientSecret')}
+
+
+ {oauthRotatedSecret}
+
+ handleCopy(oauthRotatedSecret, 'rotated-secret')}
+ className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
+ style={{ borderColor: 'var(--border-primary)' }}>
+ {copiedKey === 'rotated-secret' ? : }
+
+
+
+
+ setOauthRotatedSecret(null)}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
+ {t('settings.mcp.modal.done')}
+
+
+
+
+ )}
+
+ {/* Revoke OAuth Session confirm */}
+ {oauthRevokeId !== null && (
+
{ if (e.target === e.currentTarget) setOauthRevokeId(null) }}>
+
+
{t('settings.oauth.revokeSession')}
+
{t('settings.oauth.revokeSessionMessage')}
+
+ setOauthRevokeId(null)}
+ className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
+ {t('common.cancel')}
+
+ handleRevokeSession(oauthRevokeId)}
+ className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
+ {t('settings.oauth.revoke')}
+
+
+
+
+ )}
>
)
}
diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx
index 807b5808..2d383d8b 100644
--- a/client/src/components/Settings/MapSettingsTab.tsx
+++ b/client/src/components/Settings/MapSettingsTab.tsx
@@ -74,7 +74,7 @@ export default function MapSettingsTab(): React.ReactElement {
})
toast.success(t('settings.toast.mapSaved'))
} catch (err: unknown) {
- toast.error(err instanceof Error ? err.message : 'Error')
+ toast.error(err instanceof Error ? err.message : t('common.error'))
} finally {
setSaving(false)
}
diff --git a/client/src/components/Settings/NotificationsTab.test.tsx b/client/src/components/Settings/NotificationsTab.test.tsx
index ef894d34..b3b808a4 100644
--- a/client/src/components/Settings/NotificationsTab.test.tsx
+++ b/client/src/components/Settings/NotificationsTab.test.tsx
@@ -35,14 +35,14 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () => new Promise(() => {})),
);
render(
);
- expect(screen.getByText('Loading…')).toBeInTheDocument();
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => {
render(
);
// The event label is translated; fallback is the key itself
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Should render a toggle (ToggleSwitch renders a button)
const toggles = await screen.findAllByRole('button');
@@ -52,7 +52,7 @@ describe('NotificationsTab', () => {
it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => {
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// inapp channel header should appear (either translated or raw key)
const headers = screen.getAllByText(/inapp|in.?app/i);
@@ -72,7 +72,7 @@ describe('NotificationsTab', () => {
);
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Should show noChannels message (translated or key)
const noChannelEl = await screen.findByText(/no.*channel|noChannels/i);
@@ -97,7 +97,7 @@ describe('NotificationsTab', () => {
);
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// A dash should appear for non-implemented combos
const dashes = await screen.findAllByText('—');
@@ -116,7 +116,7 @@ describe('NotificationsTab', () => {
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// minimalMatrix has inapp:true and email:false for trip_invite
@@ -144,7 +144,7 @@ describe('NotificationsTab', () => {
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Find the inapp toggle for trip_invite — it starts as "on"
@@ -156,8 +156,8 @@ describe('NotificationsTab', () => {
// After the error, the toggle should revert back (still rendered in the DOM)
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
- expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
});
// The toggle should still be present (not removed on error)
@@ -178,20 +178,20 @@ describe('NotificationsTab', () => {
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
const toggleButtons = await screen.findAllByRole('button');
await user.click(toggleButtons[0]);
await waitFor(() => {
- expect(screen.getByText('Saving…')).toBeInTheDocument();
+ expect(screen.getByText('Saving...')).toBeInTheDocument();
});
resolveRequest();
await waitFor(() => {
- expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
});
});
@@ -209,7 +209,7 @@ describe('NotificationsTab', () => {
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
// Webhook URL input should be present
@@ -238,7 +238,7 @@ describe('NotificationsTab', () => {
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
const input = await screen.findByRole('textbox');
@@ -265,7 +265,7 @@ describe('NotificationsTab', () => {
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
const input = await screen.findByRole('textbox');
@@ -297,7 +297,7 @@ describe('NotificationsTab', () => {
render(
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
await screen.findByRole('textbox');
@@ -330,7 +330,7 @@ describe('NotificationsTab', () => {
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
const input = await screen.findByRole('textbox');
@@ -371,7 +371,7 @@ describe('NotificationsTab', () => {
);
await waitFor(() => {
- expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
const input = await screen.findByRole('textbox');
diff --git a/client/src/components/Settings/NotificationsTab.tsx b/client/src/components/Settings/NotificationsTab.tsx
index 7ee5a133..2fa109eb 100644
--- a/client/src/components/Settings/NotificationsTab.tsx
+++ b/client/src/components/Settings/NotificationsTab.tsx
@@ -107,7 +107,7 @@ export default function NotificationsTab(): React.ReactElement {
}
const renderContent = () => {
- if (!matrix) return
Loading…
+ if (!matrix) return
{t('common.loading')}
if (visibleChannels.length === 0) {
return (
@@ -119,7 +119,7 @@ export default function NotificationsTab(): React.ReactElement {
return (
- {saving &&
Saving…
}
+ {saving &&
{t('common.saving')}
}
{matrix.available_channels.webhook && (
diff --git a/client/src/components/Settings/OfflineTab.tsx b/client/src/components/Settings/OfflineTab.tsx
new file mode 100644
index 00000000..299958a0
--- /dev/null
+++ b/client/src/components/Settings/OfflineTab.tsx
@@ -0,0 +1,180 @@
+/**
+ * Offline settings tab — shows cached trips, storage info, and controls
+ * to re-sync or clear the offline cache.
+ */
+import React, { useState, useEffect, useCallback } from 'react'
+import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
+import Section from './Section'
+import { offlineDb, clearAll } from '../../db/offlineDb'
+import { tripSyncManager } from '../../sync/tripSyncManager'
+import { mutationQueue } from '../../sync/mutationQueue'
+import type { SyncMeta } from '../../db/offlineDb'
+import type { Trip } from '../../types'
+
+interface CachedTripRow {
+ trip: Trip
+ meta: SyncMeta
+ placeCount: number
+ fileCount: number
+}
+
+export default function OfflineTab(): React.ReactElement {
+ const [rows, setRows] = useState([])
+ const [pendingCount, setPendingCount] = useState(0)
+ const [syncing, setSyncing] = useState(false)
+ const [clearing, setClearing] = useState(false)
+ const [loading, setLoading] = useState(true)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ try {
+ const [metas, pending] = await Promise.all([
+ offlineDb.syncMeta.toArray(),
+ mutationQueue.pendingCount(),
+ ])
+ setPendingCount(pending)
+
+ const result: CachedTripRow[] = []
+ for (const meta of metas) {
+ const trip = await offlineDb.trips.get(meta.tripId)
+ if (!trip) continue
+ const [placeCount, fileCount] = await Promise.all([
+ offlineDb.places.where('trip_id').equals(meta.tripId).count(),
+ offlineDb.tripFiles.where('trip_id').equals(meta.tripId).count(),
+ ])
+ result.push({ trip, meta, placeCount, fileCount })
+ }
+ result.sort((a, b) => (a.trip.start_date ?? '').localeCompare(b.trip.start_date ?? ''))
+ setRows(result)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ async function handleResync() {
+ setSyncing(true)
+ try {
+ await tripSyncManager.syncAll()
+ await load()
+ } finally {
+ setSyncing(false)
+ }
+ }
+
+ async function handleClear() {
+ if (!window.confirm('Clear all offline trip data? You can re-sync anytime while online.')) return
+ setClearing(true)
+ try {
+ await clearAll()
+ await load()
+ } finally {
+ setClearing(false)
+ }
+ }
+
+ const formatDate = (d: string | null | undefined) =>
+ d ? new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'
+
+ return (
+
+
+
+ {/* Stats row */}
+
+
+
+
+
+ {/* Actions */}
+
+
+
+ {syncing ? 'Syncing…' : 'Re-sync now'}
+
+
+
+
+ Clear cache
+
+
+
+ {/* Cached trip list */}
+ {loading ? (
+
Loading…
+ ) : rows.length === 0 ? (
+
+ No trips cached yet. Connect to internet to sync.
+
+ ) : (
+
+ {rows.map(({ trip, meta, placeCount, fileCount }) => (
+
+
+
+ {trip.name}
+
+
+
+ {meta.lastSyncedAt
+ ? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
+ : '—'}
+
+
+
+ {formatDate(trip.start_date)} – {formatDate(trip.end_date)}
+ {' · '}
+ {placeCount} place{placeCount !== 1 ? 's' : ''}
+ {' · '}
+ {fileCount} file{fileCount !== 1 ? 's' : ''}
+
+
+ ))}
+
+ )}
+
+
+ )
+}
+
+function Stat({ label, value }: { label: string; value: number }) {
+ return (
+
+ )
+}
diff --git a/client/src/components/Settings/PhotoProvidersSection.tsx b/client/src/components/Settings/PhotoProvidersSection.tsx
index 4c00e292..042d5e6a 100644
--- a/client/src/components/Settings/PhotoProvidersSection.tsx
+++ b/client/src/components/Settings/PhotoProvidersSection.tsx
@@ -11,6 +11,7 @@ interface ProviderField {
label: string
input_type: string
placeholder?: string | null
+ hint?: string | null
required: boolean
secret: boolean
settings_key?: string | null
@@ -71,6 +72,10 @@ export default function PhotoProvidersSection(): React.ReactElement {
const payload: Record = {}
for (const field of getProviderFields(provider)) {
const payloadKey = field.payload_key || field.settings_key || field.key
+ if (field.input_type === 'checkbox') {
+ payload[payloadKey] = values[field.key] === 'true'
+ continue
+ }
const value = (values[field.key] || '').trim()
if (field.secret && !value) continue
payload[payloadKey] = value
@@ -102,6 +107,18 @@ export default function PhotoProvidersSection(): React.ReactElement {
const cfg = getProviderConfig(provider)
const fields = getProviderFields(provider)
+ // Seed checkbox defaults before the async settings load resolves
+ const checkboxDefaults: Record = {}
+ for (const field of fields) {
+ if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false'
+ }
+ if (Object.keys(checkboxDefaults).length > 0) {
+ setProviderValues(prev => ({
+ ...prev,
+ [provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) },
+ }))
+ }
+
if (cfg.settings_get) {
apiClient.get(cfg.settings_get).then(res => {
if (isCancelled) return
@@ -112,7 +129,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
if (field.secret) continue
const sourceKey = field.settings_key || field.payload_key || field.key
const rawValue = (res.data as Record)[sourceKey]
- nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : ''
+ if (rawValue != null) {
+ nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue)
+ } else if (field.input_type === 'checkbox') {
+ nextValues[field.key] = 'false'
+ } else {
+ nextValues[field.key] = ''
+ }
}
setProviderValues(prev => ({
...prev,
@@ -198,14 +221,31 @@ export default function PhotoProvidersSection(): React.ReactElement {
{fields.map(field => (
))}
@@ -213,7 +253,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
- title={!canSave ? 'Save route is not configured for this provider' : isProviderSaveDisabled(provider) ? 'Please fill all required fields' : ''}
+ title={!canSave ? t('memories.saveRouteNotConfigured') : isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''}
>
{t('common.save')}
@@ -221,18 +261,23 @@ export default function PhotoProvidersSection(): React.ReactElement {
onClick={() => handleTestProvider(provider)}
disabled={!canTest || testing}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
- title={!canTest ? 'Test route is not configured for this provider' : ''}
+ title={!canTest ? t('memories.testRouteNotConfigured') : ''}
>
{testing
?
:
}
{t('memories.testConnection')}
- {connected && (
+ {connected ? (
{t('memories.connected')}
+ ) : (
+
+
+ {t('memories.disconnected')}
+
)}
diff --git a/client/src/components/Todo/TodoListPanel.tsx b/client/src/components/Todo/TodoListPanel.tsx
index 10c2d392..9f8a396f 100644
--- a/client/src/components/Todo/TodoListPanel.tsx
+++ b/client/src/components/Todo/TodoListPanel.tsx
@@ -105,7 +105,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
- .catch(err => toast.error(err instanceof Error ? err.message : 'Error'))
+ .catch(err => toast.error(err instanceof Error ? err.message : t('common.error')))
}
// Get category count (non-done items)
@@ -479,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
due_date: dueDate || null, category: category || null,
assigned_user_id: assignedUserId, priority,
} as any)
- } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
+ } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
setSaving(false)
}
@@ -487,7 +487,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
try {
await deleteTodoItem(tripId, item.id)
onClose()
- } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
+ } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
}
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
@@ -663,7 +663,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
assigned_user_id: assignedUserId,
} as any)
if (item?.id) onCreated(item.id)
- } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
+ } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
setSaving(false)
}
diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx
index 76431059..47f1184a 100644
--- a/client/src/components/Trips/TripFormModal.tsx
+++ b/client/src/components/Trips/TripFormModal.tsx
@@ -46,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [uploadingCover, setUploadingCover] = useState(false)
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
const [selectedMembers, setSelectedMembers] = useState([])
+ const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
const [memberSelectValue, setMemberSelectValue] = useState('')
useEffect(() => {
@@ -74,8 +75,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {})
}
- if (!trip) {
- authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
+ authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
+ if (trip) {
+ tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
+ } else {
+ setExistingMembers([])
}
}, [trip, isOpen])
@@ -365,12 +369,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
)}
- {/* Members — only for new trips */}
- {!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
+ {/* Members */}
+ {allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
- {t('dashboard.addMembers')}
+ {isEditing ? t('dashboard.addMembers') : t('dashboard.addMembers')}
+ {/* Existing members (editing mode) */}
+ {isEditing && existingMembers.length > 0 && (
+
+ {existingMembers.map(m => (
+ {
+ if (m.id === currentUser?.id) return
+ try {
+ await tripsApi.removeMember(trip!.id, m.id)
+ setExistingMembers(prev => prev.filter(x => x.id !== m.id))
+ toast.success(t('trips.memberRemoved', { username: m.username }))
+ } catch { toast.error(t('trips.memberRemoveError')) }
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
+ background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)',
+ cursor: m.id === currentUser?.id ? 'default' : 'pointer',
+ border: '1px solid var(--border-primary)',
+ }}>
+ {m.username}
+ {m.id !== currentUser?.id && }
+
+ ))}
+
+ )}
+ {/* Newly selected members (both modes) */}
{selectedMembers.length > 0 && (
{selectedMembers.map(uid => {
@@ -393,11 +423,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
{
- if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') }
+ onChange={async value => {
+ if (!value) return
+ if (isEditing && trip?.id) {
+ const user = allUsers.find(u => u.id === Number(value))
+ if (user) {
+ try {
+ await tripsApi.addMember(trip.id, user.username)
+ setExistingMembers(prev => [...prev, { id: user.id, username: user.username }])
+ toast.success(t('trips.memberAdded', { username: user.username }))
+ } catch { toast.error(t('trips.memberAddError')) }
+ }
+ } else {
+ setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)])
+ }
+ setMemberSelectValue('')
}}
placeholder={t('dashboard.addMember')}
- options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))}
+ options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id) && !existingMembers.some(m => m.id === u.id)).map(u => ({ value: u.id, label: u.username }))}
searchable
size="sm"
/>
diff --git a/client/src/components/Trips/TripMembersModal.tsx b/client/src/components/Trips/TripMembersModal.tsx
index 47a6b548..7ef8cfed 100644
--- a/client/src/components/Trips/TripMembersModal.tsx
+++ b/client/src/components/Trips/TripMembersModal.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import { tripsApi, authApi, shareApi } from '../../api/client'
import { useToast } from '../shared/Toast'
@@ -40,6 +40,11 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
const [copied, setCopied] = useState(false)
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
const toast = useToast()
+ const copyTimerRef = useRef | null>(null)
+
+ useEffect(() => {
+ return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
+ }, [])
useEffect(() => {
shareApi.getLink(tripId).then(d => {
@@ -77,7 +82,8 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
if (shareUrl) {
navigator.clipboard.writeText(shareUrl)
setCopied(true)
- setTimeout(() => setCopied(false), 2000)
+ if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
+ copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
}
}
diff --git a/client/src/components/Vacay/VacayCalendar.test.tsx b/client/src/components/Vacay/VacayCalendar.test.tsx
index de3d4616..74c16003 100644
--- a/client/src/components/Vacay/VacayCalendar.test.tsx
+++ b/client/src/components/Vacay/VacayCalendar.test.tsx
@@ -151,7 +151,7 @@ describe('VacayCalendar', () => {
expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42)
})
- it('FE-COMP-VACAYCALENDAR-007: cell click blocked by public holiday', async () => {
+ it('FE-COMP-VACAYCALENDAR-007: cell click on public holiday toggles vacation entry', async () => {
const user = userEvent.setup()
const toggleEntry = vi.fn().mockResolvedValue(undefined)
@@ -168,10 +168,10 @@ describe('VacayCalendar', () => {
render( )
- // Month 0, button emits '2025-01-01' which is a holiday
+ // Month 0, button emits '2025-01-01' which is a holiday — should still toggle vacation
await user.click(screen.getByText('click-0'))
- expect(toggleEntry).not.toHaveBeenCalled()
+ expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', undefined)
})
it('FE-COMP-VACAYCALENDAR-008: cell click in company mode calls toggleCompanyHoliday', async () => {
diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx
index 15536df5..252c0b90 100644
--- a/client/src/components/Vacay/VacayCalendar.tsx
+++ b/client/src/components/Vacay/VacayCalendar.tsx
@@ -1,7 +1,8 @@
-import { useMemo, useState, useCallback } from 'react'
+import { useMemo, useState, useCallback, useEffect } from 'react'
import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays'
+import { tripsApi } from '../../api/client'
import VacayMonthCard from './VacayMonthCard'
import { Building2, MousePointer2 } from 'lucide-react'
@@ -9,6 +10,30 @@ export default function VacayCalendar() {
const { t } = useTranslation()
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
const [companyMode, setCompanyMode] = useState(false)
+ const [tripDates, setTripDates] = useState>(new Set())
+
+ useEffect(() => {
+ let cancelled = false
+ ;(async () => {
+ try {
+ const data = await tripsApi.list()
+ const dates = new Set()
+ for (const trip of data.trips || []) {
+ if (!trip.start_date || !trip.end_date) continue
+ const start = new Date(trip.start_date + 'T00:00:00')
+ const end = new Date(trip.end_date + 'T00:00:00')
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
+ const y = d.getFullYear()
+ if (y === selectedYear) {
+ dates.add(`${y}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
+ }
+ }
+ }
+ if (!cancelled) setTripDates(dates)
+ } catch { /* ignore */ }
+ })()
+ return () => { cancelled = true }
+ }, [selectedYear])
const companyHolidaySet = useMemo(() => {
const s = new Set()
@@ -35,17 +60,16 @@ export default function VacayCalendar() {
await toggleCompanyHoliday(dateStr)
return
}
- if (holidays[dateStr]) return
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined)
- }, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
+ }, [companyMode, toggleEntry, toggleCompanyHoliday, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
const selectedUser = users.find(u => u.id === selectedUserId)
return (
-
+
{Array.from({ length: 12 }, (_, i) => (
))}
diff --git a/client/src/components/Vacay/VacayMonthCard.tsx b/client/src/components/Vacay/VacayMonthCard.tsx
index 1e639580..07b35732 100644
--- a/client/src/components/Vacay/VacayMonthCard.tsx
+++ b/client/src/components/Vacay/VacayMonthCard.tsx
@@ -23,22 +23,26 @@ interface VacayMonthCardProps {
companyMode: boolean
blockWeekends: boolean
weekendDays?: number[]
+ tripDates?: Set
+ weekStart?: number
}
export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
- onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
+ onCellClick, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1
}: VacayMonthCardProps) {
const { t, locale } = useTranslation()
- const weekdays = WEEKDAY_KEYS.map(k => t(k))
+ const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const
+ const orderedKeys = weekStart === 0 ? WEEKDAY_KEYS_SUNDAY : WEEKDAY_KEYS
+ const weekdays = orderedKeys.map(k => t(k))
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
-
+
const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate()
- let startDow = firstDay.getDay() - 1
- if (startDow < 0) startDow = 6
+ let startDow = firstDay.getDay() - weekStart
+ if (startDow < 0) startDow += 7
const cells = []
for (let i = 0; i < startDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
@@ -50,6 +54,11 @@ export default function VacayMonthCard({
const pad = (n) => String(n).padStart(2, '0')
+ const todayStr = useMemo(() => {
+ const d = new Date()
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+ }, [])
+
return (
@@ -57,11 +66,16 @@ export default function VacayMonthCard({
- {weekdays.map((wd, i) => (
-
= 5 ? 'var(--text-faint)' : 'var(--text-muted)' }}>
- {wd}
-
- ))}
+ {weekdays.map((wd, i) => {
+ // Map column index back to JS day (0=Sun..6=Sat) to check if it's a weekend column
+ const jsDay = (i + weekStart) % 7
+ const isWeekendCol = weekendDays.includes(jsDay)
+ return (
+
+ {wd}
+
+ )
+ })}
@@ -76,7 +90,8 @@ export default function VacayMonthCard({
const holiday = holidays[dateStr]
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
const dayEntries = entryMap[dateStr] || []
- const isBlocked = !!holiday || (weekend && blockWeekends) || (isCompany && !companyMode)
+ const isBlocked = (weekend && blockWeekends) || (isCompany && !companyMode)
+ const isToday = dateStr === todayStr
return (
)}
-
+ )}
+
+
0 ? 700 : 500,
+ color: isToday
+ ? '#fff'
+ : dayEntries.length > 0
+ ? 'var(--text-primary)'
+ : holiday ? holiday.color
+ : weekend ? 'var(--text-faint)'
+ : 'var(--text-primary)',
+ ...(isToday ? {
+ display: 'inline-flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: 18,
+ height: 18,
+ borderRadius: '50%',
+ background: '#3b82f6',
+ } : {}),
}}>
{day}
diff --git a/client/src/components/Vacay/VacaySettings.test.tsx b/client/src/components/Vacay/VacaySettings.test.tsx
index c2f4a5cc..efcc310a 100644
--- a/client/src/components/Vacay/VacaySettings.test.tsx
+++ b/client/src/components/Vacay/VacaySettings.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
-import { screen, waitFor } from '@testing-library/react'
+import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -75,17 +75,7 @@ describe('VacaySettings', () => {
render(
)
// Day buttons should be visible (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
- // They have text from translation keys; in test env they fallback to keys or English
- // Check that 7 day-selector buttons exist (they are inside the paddingLeft:36 div)
- const allButtons = screen.getAllByRole('button')
- // The day buttons are not toggle buttons (no inline-flex/rounded-full class)
- const dayButtons = allButtons.filter(b =>
- !b.className.includes('inline-flex') &&
- !b.className.includes('rounded-full') &&
- !b.className.includes('rounded-md') &&
- !b.className.includes('rounded-xl') &&
- !b.className.includes('rounded-lg')
- )
+ const dayButtons = within(screen.getByTestId('weekend-days')).getAllByRole('button')
// There should be 7 day buttons
expect(dayButtons.length).toBe(7)
})
@@ -98,14 +88,8 @@ describe('VacaySettings', () => {
})
render(
)
- // When block_weekends is false, the day selector section is not rendered
- // There should only be toggle buttons (4 toggles), no day buttons
- const allButtons = screen.getAllByRole('button')
- // None of the buttons should be day selectors (they have borderRadius:8 inline style)
- const dayButtons = allButtons.filter(b =>
- b.style.borderRadius === '8px' && b.style.padding === '4px 10px'
- )
- expect(dayButtons).toHaveLength(0)
+ // When block_weekends is false, the weekend-days container is not rendered
+ expect(screen.queryByTestId('weekend-days')).toBeNull()
})
it('FE-COMP-VACAYSETTINGS-005: clicking an active weekend day removes it', async () => {
diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx
index 36b48757..124a3a1f 100644
--- a/client/src/components/Vacay/VacaySettings.tsx
+++ b/client/src/components/Vacay/VacaySettings.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
+import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -51,7 +51,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{/* Weekend days selector */}
{plan.block_weekends !== false && (
-
+
{t('vacay.weekendDays')}
{[
@@ -85,6 +85,37 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
)}
+ {/* Week start */}
+
+
+
+
+
{t('vacay.weekStart')}
+
{t('vacay.weekStartHint')}
+
+
+
+ {[
+ { value: 1, label: t('vacay.mon') },
+ { value: 0, label: t('vacay.sun') },
+ ].map(({ value, label }) => {
+ const active = (plan.week_start ?? 1) === value
+ return (
+ updatePlan({ week_start: value })}
+ style={{
+ padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
+ fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
+ background: active ? 'var(--text-primary)' : 'var(--bg-card)',
+ borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
+ color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
+ }}>
+ {label}
+
+ )
+ })}
+
+
+
{/* Carry-over */}
{ mouseDownTarget.current = e.target }}
onClick={e => {
@@ -61,7 +61,7 @@ export default function Modal({
= {
export function ToastContainer() {
const [toasts, setToasts] = useState
([])
+ const timersRef = useRef[]>([])
+
+ useEffect(() => {
+ return () => {
+ timersRef.current.forEach(clearTimeout)
+ }
+ }, [])
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) {
- setTimeout(() => {
+ const t1 = setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
- setTimeout(() => {
+ const t2 = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 400)
+ timersRef.current.push(t2)
}, duration)
+ timersRef.current.push(t1)
}
return id
@@ -47,9 +56,10 @@ export function ToastContainer() {
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
- setTimeout(() => {
+ const t = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 400)
+ timersRef.current.push(t)
}, [])
useEffect(() => {
diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts
new file mode 100644
index 00000000..224794c6
--- /dev/null
+++ b/client/src/db/offlineDb.ts
@@ -0,0 +1,193 @@
+import Dexie, { type Table } from 'dexie';
+import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types';
+
+/** TripMember enriched with tripId so we can index by trip. */
+export interface CachedTripMember extends TripMember {
+ tripId: number;
+}
+
+// ── Queue + sync types ────────────────────────────────────────────────────────
+
+export type MutationStatus = 'pending' | 'syncing' | 'failed';
+
+export interface QueuedMutation {
+ /** UUID — also used as X-Idempotency-Key sent to the server */
+ id: string;
+ tripId: number;
+ method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+ url: string;
+ body: unknown;
+ createdAt: number;
+ status: MutationStatus;
+ attempts: number;
+ lastError: string | null;
+ /** Dexie table name to write the server response into after flush (e.g. 'places') */
+ resource?: string;
+ /** For CREATE mutations enqueued offline: the temporary negative id written to Dexie */
+ tempId?: number;
+ /** For DELETE mutations: the entity id to remove from Dexie on flush */
+ entityId?: number;
+}
+
+export interface SyncMeta {
+ tripId: number;
+ lastSyncedAt: number | null;
+ status: 'idle' | 'syncing' | 'error';
+ /** Bounding box [minLng, minLat, maxLng, maxLat] of pre-downloaded map tiles */
+ tilesBbox: [number, number, number, number] | null;
+ filesCachedCount: number;
+}
+
+export interface BlobCacheEntry {
+ /** Relative URL, e.g. "/api/files/42/download" */
+ url: string;
+ blob: Blob;
+ mime: string;
+ cachedAt: number;
+}
+
+// ── Dexie class ────────────────────────────────────────────────────────────────
+
+class TrekOfflineDb extends Dexie {
+ trips!: Table;
+ days!: Table;
+ places!: Table;
+ packingItems!: Table;
+ todoItems!: Table;
+ budgetItems!: Table;
+ reservations!: Table;
+ tripFiles!: Table;
+ accommodations!: Table;
+ tripMembers!: Table;
+ tags!: Table;
+ categories!: Table;
+ mutationQueue!: Table;
+ syncMeta!: Table;
+ blobCache!: Table;
+
+ constructor() {
+ super('trek-offline');
+
+ this.version(1).stores({
+ trips: 'id',
+ days: 'id, trip_id',
+ places: 'id, trip_id',
+ packingItems: 'id, trip_id',
+ todoItems: 'id, trip_id',
+ budgetItems: 'id, trip_id',
+ reservations: 'id, trip_id',
+ tripFiles: 'id, trip_id',
+ mutationQueue:'id, tripId, status, createdAt',
+ syncMeta: 'tripId',
+ blobCache: 'url, cachedAt',
+ });
+
+ this.version(2).stores({
+ accommodations: 'id, trip_id',
+ tripMembers: '[tripId+id], tripId',
+ tags: 'id',
+ categories: 'id',
+ });
+ }
+}
+
+export const offlineDb = new TrekOfflineDb();
+
+// ── Bulk upsert helpers ────────────────────────────────────────────────────────
+
+export async function upsertTrip(trip: Trip): Promise {
+ await offlineDb.trips.put(trip);
+}
+
+export async function upsertDays(days: Day[]): Promise {
+ await offlineDb.days.bulkPut(days);
+}
+
+export async function upsertPlaces(places: Place[]): Promise {
+ await offlineDb.places.bulkPut(places);
+}
+
+export async function upsertPackingItems(items: PackingItem[]): Promise {
+ await offlineDb.packingItems.bulkPut(items);
+}
+
+export async function upsertTodoItems(items: TodoItem[]): Promise {
+ await offlineDb.todoItems.bulkPut(items);
+}
+
+export async function upsertBudgetItems(items: BudgetItem[]): Promise {
+ await offlineDb.budgetItems.bulkPut(items);
+}
+
+export async function upsertReservations(items: Reservation[]): Promise {
+ await offlineDb.reservations.bulkPut(items);
+}
+
+export async function upsertTripFiles(files: TripFile[]): Promise {
+ await offlineDb.tripFiles.bulkPut(files);
+}
+
+export async function upsertAccommodations(items: Accommodation[]): Promise {
+ await offlineDb.accommodations.bulkPut(items);
+}
+
+export async function upsertTripMembers(tripId: number, members: TripMember[]): Promise {
+ const rows: CachedTripMember[] = members.map(m => ({ ...m, tripId }));
+ await offlineDb.tripMembers.bulkPut(rows);
+}
+
+export async function upsertTags(tags: Tag[]): Promise {
+ await offlineDb.tags.bulkPut(tags);
+}
+
+export async function upsertCategories(categories: Category[]): Promise {
+ await offlineDb.categories.bulkPut(categories);
+}
+
+export async function upsertSyncMeta(meta: SyncMeta): Promise {
+ await offlineDb.syncMeta.put(meta);
+}
+
+// ── Eviction / cleanup ────────────────────────────────────────────────────────
+
+/** Delete all cached data for one trip (eviction or explicit clear). */
+export async function clearTripData(tripId: number): Promise {
+ await offlineDb.transaction(
+ 'rw',
+ [
+ offlineDb.days,
+ offlineDb.places,
+ offlineDb.packingItems,
+ offlineDb.todoItems,
+ offlineDb.budgetItems,
+ offlineDb.reservations,
+ offlineDb.tripFiles,
+ offlineDb.accommodations,
+ offlineDb.tripMembers,
+ offlineDb.mutationQueue,
+ offlineDb.syncMeta,
+ ],
+ async () => {
+ await offlineDb.days.where('trip_id').equals(tripId).delete();
+ await offlineDb.places.where('trip_id').equals(tripId).delete();
+ await offlineDb.packingItems.where('trip_id').equals(tripId).delete();
+ await offlineDb.todoItems.where('trip_id').equals(tripId).delete();
+ await offlineDb.budgetItems.where('trip_id').equals(tripId).delete();
+ await offlineDb.reservations.where('trip_id').equals(tripId).delete();
+ await offlineDb.tripFiles.where('trip_id').equals(tripId).delete();
+ await offlineDb.accommodations.where('trip_id').equals(tripId).delete();
+ await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
+ await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
+ await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
+ },
+ );
+ // Remove the trip row itself outside the transaction since it's a separate table
+ await offlineDb.trips.delete(tripId);
+}
+
+/** Wipe the entire offline database (called on logout). */
+export async function clearAll(): Promise {
+ await offlineDb.delete();
+ // Re-open so subsequent operations don't fail
+ await offlineDb.open();
+}
diff --git a/client/src/hooks/useDayNotes.ts b/client/src/hooks/useDayNotes.ts
index 296c197a..ef03154b 100644
--- a/client/src/hooks/useDayNotes.ts
+++ b/client/src/hooks/useDayNotes.ts
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react'
import { useTripStore } from '../store/tripStore'
import { useToast } from '../components/shared/Toast'
+import { useTranslation } from '../i18n'
import type { MergedItem, DayNotesMap, DayNote } from '../types'
interface NoteUiState {
@@ -21,6 +22,7 @@ export function useDayNotes(tripId: number | string) {
const noteInputRef = useRef(null)
const tripStore = useTripStore()
const toast = useToast()
+ const { t } = useTranslation()
const dayNotes: DayNotesMap = tripStore.dayNotes || {}
const openAddNote = (dayId: number, getMergedItems: (dayId: number) => MergedItem[], expandDay?: (dayId: number) => void) => {
@@ -50,12 +52,12 @@ export function useDayNotes(tripId: number | string) {
await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
}
cancelNote(dayId)
- } 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 deleteNote = async (dayId: number, noteId: number) => {
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
- 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 moveNote = async (dayId: number, noteId: number, direction: 'up' | 'down', getMergedItems: (dayId: number) => MergedItem[]) => {
@@ -71,7 +73,7 @@ export function useDayNotes(tripId: number | string) {
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
}
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
- 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')) }
}
return { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, moveNote }
diff --git a/client/src/hooks/usePendingMutations.ts b/client/src/hooks/usePendingMutations.ts
new file mode 100644
index 00000000..4d73b9aa
--- /dev/null
+++ b/client/src/hooks/usePendingMutations.ts
@@ -0,0 +1,43 @@
+/**
+ * usePendingMutations — returns the set of entity IDs that have a pending
+ * or syncing mutation for a given trip.
+ *
+ * Components use this to render a clock/pending indicator on list rows.
+ * Polls Dexie every 2 s so the indicator clears automatically once synced.
+ */
+import { useState, useEffect } from 'react'
+import { mutationQueue } from '../sync/mutationQueue'
+
+const POLL_MS = 2_000
+
+export function usePendingMutations(tripId: number): Set {
+ const [pendingIds, setPendingIds] = useState>(new Set())
+
+ useEffect(() => {
+ let cancelled = false
+
+ async function refresh() {
+ const pending = await mutationQueue.pending(tripId)
+ if (cancelled) return
+
+ const ids = new Set()
+ for (const m of pending) {
+ // Extract entity id from the mutation URL (last numeric segment)
+ const match = m.url.match(/\/(\d+)$/)
+ if (match) ids.add(Number(match[1]))
+ // Also include tempId for offline-created items
+ if (m.tempId !== undefined) ids.add(m.tempId)
+ }
+ setPendingIds(ids)
+ }
+
+ refresh()
+ const timer = setInterval(refresh, POLL_MS)
+ return () => {
+ cancelled = true
+ clearInterval(timer)
+ }
+ }, [tripId])
+
+ return pendingIds
+}
diff --git a/client/src/i18n/TranslationContext.tsx b/client/src/i18n/TranslationContext.tsx
index e9a990a8..cb2a5175 100644
--- a/client/src/i18n/TranslationContext.tsx
+++ b/client/src/i18n/TranslationContext.tsx
@@ -15,29 +15,21 @@ import ar from './translations/ar'
import br from './translations/br'
import cs from './translations/cs'
import pl from './translations/pl'
+import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
+
+export { SUPPORTED_LANGUAGES }
type TranslationStrings = Record
-export const SUPPORTED_LANGUAGES = [
- { value: 'de', label: 'Deutsch' },
- { value: 'en', label: 'English' },
- { value: 'es', label: 'Español' },
- { value: 'fr', label: 'Français' },
- { value: 'hu', label: 'Magyar' },
- { value: 'nl', label: 'Nederlands' },
- { value: 'br', label: 'Português (Brasil)' },
- { value: 'cs', label: 'Česky' },
- { value: 'pl', label: 'Polski' },
- { value: 'ru', label: 'Русский' },
- { value: 'zh', label: '简体中文' },
- { value: 'zh-TW', label: '繁體中文' },
- { value: 'it', label: 'Italiano' },
- { value: 'ar', label: 'العربية' },
- { value: 'id', label: 'Bahasa Indonesia' },
-] as const
+// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
+const translations: Record = {
+ de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
+}
-const translations: Record = { de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl }
-const LOCALES: Record = { de: 'de-DE', en: 'en-US', es: 'es-ES', fr: 'fr-FR', hu: 'hu-HU', it: 'it-IT', ru: 'ru-RU', zh: 'zh-CN', 'zh-TW': 'zh-TW', nl: 'nl-NL', id: 'id-ID', ar: 'ar-SA', br: 'pt-BR', cs: 'cs-CZ', pl: 'pl-PL' }
+// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
+const LOCALES: Record = Object.fromEntries(
+ SUPPORTED_LANGUAGES.map(l => [l.value, l.locale])
+)
const RTL_LANGUAGES = new Set(['ar'])
export function getLocaleForLanguage(language: string): string {
@@ -53,6 +45,34 @@ export function isRtlLanguage(language: string): boolean {
return RTL_LANGUAGES.has(language)
}
+// Detects the user's preferred language from the browser/OS settings and maps
+// it to one of the supported language codes. Returns null if no match is found.
+export function detectBrowserLanguage(): string | null {
+ if (typeof navigator === 'undefined') return null
+ const browserLangs = navigator.languages?.length
+ ? navigator.languages
+ : navigator.language ? [navigator.language] : []
+ const supported = SUPPORTED_LANGUAGES.map(l => l.value)
+
+ for (const lang of browserLangs) {
+ // Exact match (e.g. 'de', 'zh-TW') — case-insensitive
+ const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
+ if (exactMatch) return exactMatch
+
+ // pt-BR has no exact match (our code is 'br', not 'pt-BR'), so map it explicitly.
+ // pt-PT and bare 'pt' are NOT mapped — they fall through to null and let the
+ // server default or 'en' fallback apply instead.
+ if (lang.toLowerCase() === 'pt-br') return 'br'
+
+ // Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') — case-insensitive
+ const prefix = lang.split('-')[0].toLowerCase()
+ const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
+ if (prefixMatch) return prefixMatch
+ }
+
+ return null
+}
+
interface TranslationContextValue {
t: (key: string, params?: Record) => string
language: string
diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts
index 4d221cd0..6895bdf2 100644
--- a/client/src/i18n/index.ts
+++ b/client/src/i18n/index.ts
@@ -4,5 +4,6 @@ export {
getLocaleForLanguage,
getIntlLanguage,
isRtlLanguage,
+ detectBrowserLanguage,
SUPPORTED_LANGUAGES,
} from './TranslationContext'
diff --git a/client/src/i18n/supportedLanguages.ts b/client/src/i18n/supportedLanguages.ts
new file mode 100644
index 00000000..458a9fff
--- /dev/null
+++ b/client/src/i18n/supportedLanguages.ts
@@ -0,0 +1,21 @@
+export const SUPPORTED_LANGUAGES = [
+ { value: 'de', label: 'Deutsch', locale: 'de-DE' },
+ { value: 'en', label: 'English', locale: 'en-US' },
+ { value: 'es', label: 'Español', locale: 'es-ES' },
+ { value: 'fr', label: 'Français', locale: 'fr-FR' },
+ { value: 'hu', label: 'Magyar', locale: 'hu-HU' },
+ { value: 'nl', label: 'Nederlands', locale: 'nl-NL' },
+ { value: 'br', label: 'Português (Brasil)', locale: 'pt-BR' },
+ { value: 'cs', label: 'Česky', locale: 'cs-CZ' },
+ { value: 'pl', label: 'Polski', locale: 'pl-PL' },
+ { value: 'ru', label: 'Русский', locale: 'ru-RU' },
+ { value: 'zh', label: '简体中文', locale: 'zh-CN' },
+ { value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
+ { value: 'it', label: 'Italiano', locale: 'it-IT' },
+ { value: 'ar', label: 'العربية', locale: 'ar-SA' },
+ { value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
+] as const
+
+export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
+
+export const SUPPORTED_LANGUAGE_CODES: string[] = SUPPORTED_LANGUAGES.map(l => l.value)
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index cdcac563..92c23eec 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -5,6 +5,8 @@ const ar: Record = {
// Common
'common.save': 'حفظ',
+ 'common.showMore': 'عرض المزيد',
+ 'common.showLess': 'عرض أقل',
'common.cancel': 'إلغاء',
'common.delete': 'حذف',
'common.edit': 'تعديل',
@@ -12,6 +14,8 @@ const ar: Record = {
'common.loading': 'جارٍ التحميل...',
'common.import': 'استيراد',
'common.error': 'خطأ',
+ 'common.unknownError': 'خطأ غير معروف',
+ 'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
'common.back': 'رجوع',
'common.all': 'الكل',
'common.close': 'إغلاق',
@@ -31,6 +35,12 @@ const ar: Record = {
'common.password': 'كلمة المرور',
'common.saving': 'جارٍ الحفظ...',
'common.saved': 'تم الحفظ',
+ 'common.expand': 'توسيع',
+ 'common.collapse': 'طي',
+ 'trips.memberRemoved': '{username} تمت إزالته',
+ 'trips.memberRemoveError': 'فشل في الإزالة',
+ 'trips.memberAdded': '{username} تمت إضافته',
+ 'trips.memberAddError': 'فشل في الإضافة',
'trips.reminder': 'تذكير',
'trips.reminderNone': 'بدون',
'trips.reminderDay': 'يوم',
@@ -139,6 +149,7 @@ const ar: Record = {
'settings.tabs.notifications': 'الإشعارات',
'settings.tabs.integrations': 'التكاملات',
'settings.tabs.account': 'الحساب',
+ 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'حول',
'settings.map': 'الخريطة',
'settings.mapTemplate': 'قالب الخريطة',
@@ -184,9 +195,6 @@ const ar: Record = {
'admin.notifications.none': 'معطّل',
'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
'admin.notifications.webhook': 'Webhook',
- 'admin.notifications.events': 'أحداث الإشعارات',
- 'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
- 'admin.notifications.configureFirst': 'قم بتكوين إعدادات SMTP أو Webhook أدناه أولاً، ثم قم بتفعيل الأحداث.',
'admin.notifications.save': 'حفظ إعدادات الإشعارات',
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
@@ -233,6 +241,7 @@ const ar: Record = {
'settings.mcp.endpoint': 'نقطة نهاية MCP',
'settings.mcp.clientConfig': 'إعداد العميل',
'settings.mcp.clientConfigHint': 'استبدل برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
+ 'settings.mcp.clientConfigHintOAuth': 'استبدل و ببيانات الاعتماد المعروضة في عميل OAuth 2.1 الذي أنشأته أعلاه. سيفتح mcp-remote متصفحك لإتمام التفويض في أول اتصال. قد يحتاج مسار npx إلى تعديل حسب نظامك (مثال: C:\PROGRA~1\nodejs\npx.cmd على Windows).',
'settings.mcp.copy': 'نسخ',
'settings.mcp.copied': 'تم النسخ!',
'settings.mcp.apiTokens': 'رموز API',
@@ -254,6 +263,48 @@ const ar: Record = {
'settings.mcp.toast.createError': 'فشل إنشاء الرمز',
'settings.mcp.toast.deleted': 'تم حذف الرمز',
'settings.mcp.toast.deleteError': 'فشل حذف الرمز',
+ 'settings.mcp.apiTokensDeprecated': 'رموز API قديمة وستُزال في إصدار مستقبلي. يُرجى استخدام عملاء OAuth 2.1 بدلاً منها.',
+ 'settings.oauth.clients': 'عملاء OAuth 2.1',
+ 'settings.oauth.clientsHint': 'سجّل عملاء OAuth 2.1 للسماح لتطبيقات MCP الخارجية (Claude Web وCursor وغيرها) بالاتصال دون رموز ثابتة.',
+ 'settings.oauth.createClient': 'عميل جديد',
+ 'settings.oauth.noClients': 'لا يوجد عملاء OAuth مسجلون.',
+ 'settings.oauth.clientId': 'معرّف العميل',
+ 'settings.oauth.clientSecret': 'سر العميل',
+ 'settings.oauth.deleteClient': 'حذف العميل',
+ 'settings.oauth.deleteClientMessage': 'سيتم حذف هذا العميل وجميع الجلسات النشطة بشكل دائم. ستفقد أي تطبيق يستخدمه وصوله فوراً.',
+ 'settings.oauth.rotateSecret': 'تجديد السر',
+ 'settings.oauth.rotateSecretMessage': 'سيتم إنشاء سر عميل جديد وإبطال جميع الجلسات الحالية فوراً. حدّث تطبيقك قبل إغلاق هذا الحوار.',
+ 'settings.oauth.rotateSecretConfirm': 'تجديد',
+ 'settings.oauth.rotateSecretConfirming': 'جارٍ التجديد…',
+ 'settings.oauth.rotateSecretDoneTitle': 'تم إنشاء سر جديد',
+ 'settings.oauth.rotateSecretDoneWarning': 'يُعرض هذا السر مرة واحدة فقط. انسخه الآن وحدّث تطبيقك — تم إبطال جميع الجلسات السابقة.',
+ 'settings.oauth.activeSessions': 'جلسات OAuth النشطة',
+ 'settings.oauth.sessionScopes': 'النطاقات',
+ 'settings.oauth.sessionExpires': 'تنتهي',
+ 'settings.oauth.revoke': 'إلغاء',
+ 'settings.oauth.revokeSession': 'إلغاء الجلسة',
+ 'settings.oauth.revokeSessionMessage': 'سيؤدي هذا إلى إلغاء الوصول لهذه الجلسة OAuth فوراً.',
+ 'settings.oauth.modal.createTitle': 'تسجيل عميل OAuth',
+ 'settings.oauth.modal.presets': 'إعدادات سريعة',
+ 'settings.oauth.modal.clientName': 'اسم التطبيق',
+ 'settings.oauth.modal.clientNamePlaceholder': 'مثال: Claude Web، تطبيق MCP الخاص بي',
+ 'settings.oauth.modal.redirectUris': 'عناوين URI لإعادة التوجيه',
+ 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
+ 'settings.oauth.modal.redirectUrisHint': 'عنوان URI واحد لكل سطر. يُطلب HTTPS (localhost مستثنى). يُطبق تطابق دقيق.',
+ 'settings.oauth.modal.scopes': 'النطاقات المسموح بها',
+ 'settings.oauth.modal.scopesHint': 'list_trips وget_trip_summary متاحان دائماً — لا يُطلب نطاق. يساعدان الذكاء الاصطناعي في اكتشاف معرّفات الرحلات.',
+ 'settings.oauth.modal.selectAll': 'تحديد الكل',
+ 'settings.oauth.modal.deselectAll': 'إلغاء تحديد الكل',
+ 'settings.oauth.modal.creating': 'جارٍ التسجيل…',
+ 'settings.oauth.modal.create': 'تسجيل العميل',
+ 'settings.oauth.modal.createdTitle': 'تم تسجيل العميل',
+ 'settings.oauth.modal.createdWarning': 'يُعرض سر العميل مرة واحدة فقط. انسخه الآن — لا يمكن استرداده.',
+ 'settings.oauth.toast.createError': 'فشل تسجيل عميل OAuth',
+ 'settings.oauth.toast.deleted': 'تم حذف عميل OAuth',
+ 'settings.oauth.toast.deleteError': 'فشل حذف عميل OAuth',
+ 'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
+ 'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
+ 'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
'settings.account': 'الحساب',
'settings.about': 'حول',
'settings.about.reportBug': 'الإبلاغ عن خطأ',
@@ -376,6 +427,10 @@ const ar: Record = {
'login.mfaHint': 'افتح Google Authenticator أو Authy أو أي تطبيق TOTP آخر.',
'login.mfaBack': '← العودة لتسجيل الدخول',
'login.mfaVerify': 'تحقق',
+ 'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية',
+ 'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
+ 'login.usernameRequired': 'اسم المستخدم مطلوب',
+ 'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
// Register
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
@@ -410,9 +465,10 @@ const ar: Record = {
'admin.tabs.config': 'التخصيص',
'admin.tabs.templates': 'قوالب التعبئة',
'admin.tabs.addons': 'الإضافات',
- 'admin.tabs.mcpTokens': 'رموز MCP',
- 'admin.mcpTokens.title': 'رموز MCP',
- 'admin.mcpTokens.subtitle': 'إدارة رموز API لجميع المستخدمين',
+ 'admin.tabs.mcpTokens': 'وصول MCP',
+ 'admin.mcpTokens.title': 'وصول MCP',
+ 'admin.mcpTokens.subtitle': 'إدارة جلسات OAuth ورموز API لجميع المستخدمين',
+ 'admin.mcpTokens.sectionTitle': 'رموز API',
'admin.mcpTokens.owner': 'المالك',
'admin.mcpTokens.tokenName': 'اسم الرمز',
'admin.mcpTokens.created': 'تاريخ الإنشاء',
@@ -424,6 +480,17 @@ const ar: Record = {
'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز',
'admin.mcpTokens.deleteError': 'فشل حذف الرمز',
'admin.mcpTokens.loadError': 'فشل تحميل الرموز',
+ 'admin.oauthSessions.sectionTitle': 'جلسات OAuth',
+ 'admin.oauthSessions.clientName': 'العميل',
+ 'admin.oauthSessions.owner': 'المالك',
+ 'admin.oauthSessions.scopes': 'الصلاحيات',
+ 'admin.oauthSessions.created': 'تاريخ الإنشاء',
+ 'admin.oauthSessions.empty': 'لا توجد جلسات OAuth نشطة',
+ 'admin.oauthSessions.revokeTitle': 'إلغاء الجلسة',
+ 'admin.oauthSessions.revokeMessage': 'سيتم إلغاء جلسة OAuth هذه فوراً. سيفقد العميل وصوله إلى MCP.',
+ 'admin.oauthSessions.revokeSuccess': 'تم إلغاء الجلسة',
+ 'admin.oauthSessions.revokeError': 'فشل إلغاء الجلسة',
+ 'admin.oauthSessions.loadError': 'فشل تحميل جلسات OAuth',
'admin.tabs.github': 'GitHub',
'admin.stats.users': 'المستخدمون',
'admin.stats.trips': 'الرحلات',
@@ -473,6 +540,17 @@ const ar: Record = {
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
'admin.allowRegistration': 'السماح بالتسجيل',
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
+ 'admin.authMethods': 'Authentication Methods',
+ 'admin.passwordLogin': 'Password Login',
+ 'admin.passwordLoginHint': 'Allow users to sign in with email and password',
+ 'admin.passwordRegistration': 'Password Registration',
+ 'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
+ 'admin.oidcLogin': 'SSO Login',
+ 'admin.oidcLoginHint': 'Allow users to sign in with SSO',
+ 'admin.oidcRegistration': 'SSO Auto-Provisioning',
+ 'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
+ 'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
+ 'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
'admin.apiKeys': 'مفاتيح API',
@@ -664,6 +742,8 @@ const ar: Record = {
'vacay.companyHolidays': 'عطل الشركة',
'vacay.companyHolidaysHint': 'السماح بوضع علامة على أيام عطلات الشركة',
'vacay.companyHolidaysNoDeduct': 'لا تُخصم عطل الشركة من أيام الإجازة.',
+ 'vacay.weekStart': 'يبدأ الأسبوع في',
+ 'vacay.weekStartHint': 'اختر ما إذا كان الأسبوع يبدأ يوم الاثنين أو الأحد',
'vacay.carryOver': 'الترحيل',
'vacay.carryOverHint': 'ترحيل أيام الإجازة المتبقية تلقائيًا إلى السنة التالية',
'vacay.sharing': 'المشاركة',
@@ -812,15 +892,29 @@ const ar: Record = {
// Places Sidebar
'places.addPlace': 'إضافة مكان/نشاط',
- 'places.importGpx': 'GPX',
+ 'places.importFile': 'استيراد ملف',
+ 'places.sidebarDrop': 'أفلت للاستيراد',
+ 'places.importFileHint': 'استورد ملفات .gpx أو .kml أو .kmz من أدوات مثل Google My Maps وGoogle Earth أو جهاز تتبع GPS.',
+ 'places.importFileDropHere': 'انقر لاختيار ملف أو اسحبه وأفلته هنا',
+ 'places.importFileDropActive': 'أفلت الملف للاختيار',
+ 'places.importFileUnsupported': 'نوع الملف غير مدعوم. استخدم .gpx أو .kml أو .kmz.',
+ 'places.importFileTooLarge': 'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.',
+ 'places.importFileError': 'فشل الاستيراد',
+ 'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
- 'places.gpxError': 'فشل استيراد GPX',
+ 'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
+ 'places.urlResolved': 'تم استيراد المكان من الرابط',
+ 'places.importList': 'استيراد قائمة',
+ 'places.kmlKmzSummaryValues': 'علامات المواضع: {total} • تم الاستيراد: {created} • تم التجاوز: {skipped}',
'places.importGoogleList': 'قائمة Google',
+ 'places.importNaverList': 'قائمة Naver',
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
'places.googleListError': 'فشل استيراد قائمة Google Maps',
+ 'places.naverListHint': 'الصق رابط قائمة Naver Maps مشتركة لاستيراد جميع الأماكن.',
+ 'places.naverListImported': 'تم استيراد {count} مكان من "{list}"',
+ 'places.naverListError': 'فشل استيراد قائمة Naver Maps',
'places.viewDetails': 'عرض التفاصيل',
- 'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
'places.all': 'الكل',
'places.unplanned': 'غير مخطط',
@@ -850,11 +944,13 @@ const ar: Record = {
'places.endTimeBeforeStart': 'وقت النهاية قبل وقت البداية',
'places.timeCollision': 'تداخل في الوقت مع:',
'places.formWebsite': 'الموقع الإلكتروني',
+ 'places.formNotes': 'ملاحظات',
'places.formNotesPlaceholder': 'ملاحظات شخصية...',
'places.formReservation': 'حجز',
'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...',
'places.mapsSearchPlaceholder': 'ابحث عن أماكن...',
'places.mapsSearchError': 'فشل البحث عن المكان.',
+ 'places.loadingDetails': 'جارٍ تحميل تفاصيل المكان…',
'places.osmHint': 'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.',
'places.osmActive': 'البحث عبر OpenStreetMap (بدون صور أو تقييمات أو ساعات عمل). أضف مفتاح Google API في الإعدادات لبيانات موسعة.',
'places.categoryCreateError': 'فشل إنشاء الفئة',
@@ -869,6 +965,7 @@ const ar: Record = {
'inspector.files': 'الملفات',
'inspector.filesCount': '{count} ملفات',
'inspector.removeFromDay': 'إزالة من اليوم',
+ 'inspector.remove': 'إزالة',
'inspector.addToDay': 'إضافة إلى اليوم',
'inspector.confirmedRes': 'حجز مؤكد',
'inspector.pendingRes': 'حجز قيد الانتظار',
@@ -1009,6 +1106,7 @@ const ar: Record = {
'budget.totalBudget': 'إجمالي الميزانية',
'budget.byCategory': 'حسب الفئة',
'budget.editTooltip': 'انقر للتعديل',
+ 'budget.linkedToReservation': 'مرتبط بحجز — عدّل الاسم هناك',
'budget.confirm.deleteCategory': 'هل تريد حذف الفئة "{name}" مع {count} إدخالات؟',
'budget.deleteCategory': 'حذف الفئة',
'budget.perPerson': 'لكل شخص',
@@ -1021,6 +1119,9 @@ const ar: Record = {
// Files
'files.title': 'الملفات',
+ 'files.pageTitle': 'الملفات والمستندات',
+ 'files.subtitle': '{count} ملف لـ {trip}',
+ 'files.downloadPdf': 'تنزيل PDF',
'files.count': '{count} ملفات',
'files.countSingular': 'ملف واحد',
'files.uploaded': 'تم رفع {count}',
@@ -1099,7 +1200,6 @@ const ar: Record = {
'packing.menuCheckAll': 'تحديد الكل',
'packing.menuUncheckAll': 'إلغاء تحديد الكل',
'packing.menuDeleteCat': 'حذف الفئة',
- 'packing.assignUser': 'تعيين مستخدم',
'packing.noMembers': 'لا أعضاء',
'packing.addItem': 'إضافة عنصر',
'packing.addItemPlaceholder': 'اسم العنصر...',
@@ -1109,6 +1209,9 @@ const ar: Record = {
'packing.template': 'قالب',
'packing.templateApplied': 'تمت إضافة {count} عنصر من القالب',
'packing.templateError': 'فشل تطبيق القالب',
+ 'packing.saveAsTemplate': 'حفظ كقالب',
+ 'packing.templateName': 'اسم القالب',
+ 'packing.templateSaved': 'تم حفظ قائمة الحقائب كقالب',
'packing.bags': 'أمتعة',
'packing.noBag': 'غير معيّن',
'packing.totalWeight': 'الوزن الإجمالي',
@@ -1264,6 +1367,13 @@ const ar: Record = {
'backup.keep.forever': 'الاحتفاظ للأبد',
// Photos
+ 'photos.title': 'صور',
+ 'photos.subtitle': '{count} صورة لـ {trip}',
+ 'photos.dropHere': 'أسقط الصور هنا...',
+ 'photos.dropHereActive': 'أسقط الصور هنا',
+ 'photos.captionForAll': 'تعليق (للجميع)',
+ 'photos.captionPlaceholder': 'تعليق اختياري...',
+ 'photos.addCaption': 'إضافة تعليق...',
'photos.allDays': 'كل الأيام',
'photos.noPhotos': 'لا توجد صور بعد',
'photos.uploadHint': 'ارفع صور رحلتك',
@@ -1271,6 +1381,12 @@ const ar: Record = {
'photos.linkPlace': 'ربط بمكان',
'photos.noPlace': 'بلا مكان',
'photos.uploadN': 'رفع {n} صورة',
+ 'photos.linkDay': 'ربط اليوم',
+ 'photos.noDay': 'لا يوم',
+ 'photos.dayLabel': 'اليوم {number}',
+ 'photos.photoSelected': 'صورة محددة',
+ 'photos.photosSelected': 'صور محددة',
+ 'photos.fileTypeHint': 'JPG, PNG, WebP · الحد الأقصى 10 ميغابايت · حتى 30 صورة',
// Backup restore modal
'backup.restoreConfirmTitle': 'استعادة النسخة الاحتياطية؟',
@@ -1297,6 +1413,7 @@ const ar: Record = {
'planner.routeCalculated': 'تم حساب المسار',
'planner.routeCalcFailed': 'تعذر حساب المسار',
'planner.routeError': 'خطأ أثناء حساب المسار',
+ 'planner.icsExportFailed': 'فشل تصدير ICS',
'planner.routeOptimized': 'تم تحسين المسار',
'planner.reservationUpdated': 'تم تحديث الحجز',
'planner.reservationAdded': 'تمت إضافة الحجز',
@@ -1382,6 +1499,7 @@ const ar: Record = {
'memories.title': 'صور',
'memories.notConnected': 'Immich غير متصل',
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
+ 'memories.notConnectedMultipleHint': 'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.',
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
'memories.noPhotos': 'لم يتم العثور على صور',
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
@@ -1392,26 +1510,38 @@ const ar: Record = {
'memories.reviewTitle': 'مراجعة صورك',
'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.',
'memories.shareCount': 'مشاركة {count} صور',
- 'memories.immichUrl': 'عنوان خادم Immich',
- 'memories.immichApiKey': 'مفتاح API',
+ 'memories.providerUrl': 'عنوان URL للخادم',
+ 'memories.providerApiKey': 'مفتاح API',
+ 'memories.providerUsername': 'اسم المستخدم',
+ 'memories.providerPassword': 'كلمة المرور',
+ 'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
+ 'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
+ 'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال',
'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل',
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
'memories.connectionError': 'تعذر الاتصال بـ Immich',
- 'memories.saved': 'تم حفظ إعدادات Immich',
+ 'memories.saved': 'تم حفظ إعدادات {provider_name}',
+ 'memories.providerDisconnectedBanner': 'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
+ 'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}',
+ 'memories.saveRouteNotConfigured': 'مسار الحفظ غير مهيأ لهذا المزود',
+ 'memories.testRouteNotConfigured': 'مسار الاختبار غير مهيأ لهذا المزود',
+ 'memories.fillRequiredFields': 'يرجى ملء جميع الحقول المطلوبة',
'memories.oldest': 'الأقدم أولاً',
'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع',
'memories.addPhotos': 'إضافة صور',
'memories.linkAlbum': 'ربط ألبوم',
'memories.selectAlbum': 'اختيار ألبوم Immich',
+ 'memories.selectAlbumMultiple': 'اختيار ألبوم',
'memories.noAlbums': 'لم يتم العثور على ألبومات',
'memories.syncAlbum': 'مزامنة الألبوم',
'memories.unlinkAlbum': 'إلغاء الربط',
'memories.photos': 'صور',
'memories.selectPhotos': 'اختيار صور من Immich',
+ 'memories.selectPhotosMultiple': 'اختيار الصور',
'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد',
'memories.addSelected': 'إضافة {count} صور',
@@ -1423,6 +1553,49 @@ const ar: Record = {
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور',
+ 'journey.settings.failedToDelete': 'فشل في الحذف',
+ 'journey.entries.deleteTitle': 'حذف الإدخال',
+ 'journey.photosUploaded': 'تم رفع {count} صورة',
+ 'journey.photosAdded': 'تمت إضافة {count} صورة',
+ 'journey.picker.tripPeriod': 'فترة الرحلة',
+ 'journey.picker.dateRange': 'نطاق التاريخ',
+ 'journey.picker.allPhotos': 'كل الصور',
+ 'journey.picker.albums': 'ألبومات',
+ 'journey.picker.selected': 'محدد',
+ 'journey.picker.addTo': 'إضافة إلى',
+ 'journey.picker.newGallery': 'معرض جديد',
+ 'journey.picker.selectAll': 'تحديد الكل',
+ 'journey.picker.deselectAll': 'إلغاء تحديد الكل',
+ 'journey.picker.noAlbums': 'لم يتم العثور على ألبومات',
+ 'journey.picker.selectDate': 'اختر تاريخ',
+ 'journey.picker.search': 'بحث',
+
+ // Journey Detail
+ 'journey.detail.photos': 'صور',
+ 'journey.detail.backToJourney': 'العودة للمجلة',
+ 'journey.detail.day': 'اليوم {number}',
+ 'journey.detail.places': 'أماكن',
+ 'journey.skeletons.show': 'إظهار الاقتراحات',
+ 'journey.skeletons.hide': 'إخفاء الاقتراحات',
+
+ // Journey — Invite
+ 'journey.invite.role': 'الدور',
+ 'journey.invite.viewer': 'مشاهد',
+ 'journey.invite.editor': 'محرر',
+ 'journey.invite.invite': 'دعوة',
+ 'journey.invite.inviting': 'جارٍ الدعوة...',
+
+ // Journey Entry Editor
+ 'journey.editor.uploadPhotos': 'رفع صور',
+ 'journey.editor.uploading': '...جارٍ الرفع',
+ 'journey.editor.fromGallery': 'من المعرض',
+ 'journey.editor.addAnother': 'إضافة آخر',
+ 'journey.editor.makeFirst': 'جعله الأول',
+ 'journey.editor.searching': 'جارٍ البحث...',
+
+ // Journey — Share
+ 'journey.share.copy': 'نسخ',
+ 'journey.share.copied': 'تم النسخ!',
// Collab Addon
'collab.tabs.chat': 'الدردشة',
@@ -1553,7 +1726,9 @@ const ar: Record = {
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
'undo.lock': 'تم تبديل قفل المكان',
'undo.importGpx': 'استيراد GPX',
+ 'undo.importKeyholeMarkup': 'استيراد KMZ/KML',
'undo.importGoogleList': 'استيراد خرائط Google',
+ 'undo.importNaverList': 'استيراد خرائط Naver',
// Notifications
'notifications.title': 'الإشعارات',
@@ -1568,6 +1743,8 @@ const ar: Record = {
'notifications.markUnread': 'تحديد كغير مقروء',
'notifications.delete': 'حذف',
'notifications.system': 'النظام',
+ 'notifications.synologySessionCleared.title': 'تم قطع اتصال Synology Photos',
+ 'notifications.synologySessionCleared.text': 'تغير خادمك أو حسابك — انتقل إلى الإعدادات لاختبار اتصالك مرة أخرى.',
'memories.error.loadAlbums': 'فشل تحميل الألبومات',
'memories.error.linkAlbum': 'فشل ربط الألبوم',
'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم',
@@ -1690,6 +1867,70 @@ const ar: Record = {
'notif.generic.text': 'لديك إشعار جديد',
'notif.dev.unknown_event.title': '[DEV] حدث غير معروف',
'notif.dev.unknown_event.text': 'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG',
+
+ // OAuth scope groups
+ 'oauth.scope.group.trips': 'الرحلات',
+ 'oauth.scope.group.places': 'الأماكن',
+ 'oauth.scope.group.atlas': 'Atlas',
+ 'oauth.scope.group.packing': 'الأمتعة',
+ 'oauth.scope.group.todos': 'المهام',
+ 'oauth.scope.group.budget': 'الميزانية',
+ 'oauth.scope.group.reservations': 'الحجوزات',
+ 'oauth.scope.group.collab': 'التعاون',
+ 'oauth.scope.group.notifications': 'الإشعارات',
+ 'oauth.scope.group.vacay': 'الإجازة',
+ 'oauth.scope.group.geo': 'Geo',
+ 'oauth.scope.group.weather': 'الطقس',
+
+ // OAuth scope labels & descriptions
+ 'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر',
+ 'oauth.scope.trips:read.description': 'قراءة الرحلات والأيام والملاحظات والأعضاء',
+ 'oauth.scope.trips:write.label': 'تحرير الرحلات وخطط السفر',
+ 'oauth.scope.trips:write.description': 'إنشاء وتحديث الرحلات والأيام والملاحظات وإدارة الأعضاء',
+ 'oauth.scope.trips:delete.label': 'حذف الرحلات',
+ 'oauth.scope.trips:delete.description': 'حذف الرحلات بأكملها نهائياً — هذا الإجراء لا يمكن التراجع عنه',
+ 'oauth.scope.trips:share.label': 'إدارة روابط المشاركة',
+ 'oauth.scope.trips:share.description': 'إنشاء روابط مشاركة عامة وتحديثها وإلغاؤها',
+ 'oauth.scope.places:read.label': 'عرض الأماكن وبيانات الخريطة',
+ 'oauth.scope.places:read.description': 'قراءة الأماكن وتعيينات الأيام والعلامات والفئات',
+ 'oauth.scope.places:write.label': 'إدارة الأماكن',
+ 'oauth.scope.places:write.description': 'إنشاء وتحديث وحذف الأماكن والتعيينات والعلامات',
+ 'oauth.scope.atlas:read.label': 'عرض Atlas',
+ 'oauth.scope.atlas:read.description': 'قراءة الدول والمناطق المزارة وقائمة الأمنيات',
+ 'oauth.scope.atlas:write.label': 'إدارة Atlas',
+ 'oauth.scope.atlas:write.description': 'تعليم الدول والمناطق كمزارة، وإدارة قائمة الأمنيات',
+ 'oauth.scope.packing:read.label': 'عرض قوائم الأمتعة',
+ 'oauth.scope.packing:read.description': 'قراءة عناصر الأمتعة والحقائب ومُسنَدي الفئات',
+ 'oauth.scope.packing:write.label': 'إدارة قوائم الأمتعة',
+ 'oauth.scope.packing:write.description': 'إضافة وتحديث وحذف وتبديل وإعادة ترتيب عناصر الأمتعة والحقائب',
+ 'oauth.scope.todos:read.label': 'عرض قوائم المهام',
+ 'oauth.scope.todos:read.description': 'قراءة مهام الرحلة ومُسنَدي الفئات',
+ 'oauth.scope.todos:write.label': 'إدارة قوائم المهام',
+ 'oauth.scope.todos:write.description': 'إنشاء وتحديث وتبديل وحذف وإعادة ترتيب المهام',
+ 'oauth.scope.budget:read.label': 'عرض الميزانية',
+ 'oauth.scope.budget:read.description': 'قراءة بنود الميزانية وتفاصيل النفقات',
+ 'oauth.scope.budget:write.label': 'إدارة الميزانية',
+ 'oauth.scope.budget:write.description': 'إنشاء وتحديث وحذف بنود الميزانية',
+ 'oauth.scope.reservations:read.label': 'عرض الحجوزات',
+ 'oauth.scope.reservations:read.description': 'قراءة الحجوزات وتفاصيل الإقامة',
+ 'oauth.scope.reservations:write.label': 'إدارة الحجوزات',
+ 'oauth.scope.reservations:write.description': 'إنشاء وتحديث وحذف وإعادة ترتيب الحجوزات',
+ 'oauth.scope.collab:read.label': 'عرض التعاون',
+ 'oauth.scope.collab:read.description': 'قراءة ملاحظات التعاون والاستطلاعات والرسائل',
+ 'oauth.scope.collab:write.label': 'إدارة التعاون',
+ 'oauth.scope.collab:write.description': 'إنشاء وتحديث وحذف الملاحظات والاستطلاعات والرسائل التعاونية',
+ 'oauth.scope.notifications:read.label': 'عرض الإشعارات',
+ 'oauth.scope.notifications:read.description': 'قراءة إشعارات التطبيق وأعداد غير المقروءة',
+ 'oauth.scope.notifications:write.label': 'إدارة الإشعارات',
+ 'oauth.scope.notifications:write.description': 'تعليم الإشعارات كمقروءة والرد عليها',
+ 'oauth.scope.vacay:read.label': 'عرض خطط الإجازة',
+ 'oauth.scope.vacay:read.description': 'قراءة بيانات تخطيط الإجازة والإدخالات والإحصاءات',
+ 'oauth.scope.vacay:write.label': 'إدارة خطط الإجازة',
+ 'oauth.scope.vacay:write.description': 'إنشاء وإدارة إدخالات الإجازة والعطلات وخطط الفريق',
+ 'oauth.scope.geo:read.label': 'الخرائط والترميز الجغرافي',
+ 'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
+ 'oauth.scope.weather:read.label': 'توقعات الطقس',
+ 'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
}
export default ar
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index d3b11e66..346bafc9 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -1,6 +1,8 @@
const br: Record = {
// Common
'common.save': 'Salvar',
+ 'common.showMore': 'Mostrar mais',
+ 'common.showLess': 'Mostrar menos',
'common.cancel': 'Cancelar',
'common.delete': 'Excluir',
'common.edit': 'Editar',
@@ -8,6 +10,8 @@ const br: Record = {
'common.loading': 'Carregando...',
'common.import': 'Importar',
'common.error': 'Erro',
+ 'common.unknownError': 'Erro desconhecido',
+ 'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
'common.back': 'Voltar',
'common.all': 'Todos',
'common.close': 'Fechar',
@@ -27,11 +31,17 @@ const br: Record = {
'common.password': 'Senha',
'common.saving': 'Salvando...',
'common.saved': 'Salvo',
+ 'common.expand': 'Expandir',
+ 'common.collapse': 'Recolher',
'trips.reminder': 'Lembrete',
'trips.reminderNone': 'Nenhum',
'trips.reminderDay': 'dia',
'trips.reminderDays': 'dias',
'trips.reminderCustom': 'Personalizado',
+ 'trips.memberRemoved': '{username} removido',
+ 'trips.memberRemoveError': 'Falha ao remover',
+ 'trips.memberAdded': '{username} adicionado',
+ 'trips.memberAddError': 'Falha ao adicionar',
'trips.reminderDaysBefore': 'dias antes da partida',
'trips.reminderDisabledHint': 'Os lembretes de viagem estão desativados. Ative-os em Admin > Configurações > Notificações.',
'common.update': 'Atualizar',
@@ -134,6 +144,7 @@ const br: Record = {
'settings.tabs.notifications': 'Notificações',
'settings.tabs.integrations': 'Integrações',
'settings.tabs.account': 'Conta',
+ 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Sobre',
'settings.map': 'Mapa',
'settings.mapTemplate': 'Modelo de mapa',
@@ -179,9 +190,6 @@ const br: Record = {
'admin.notifications.none': 'Desativado',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
- 'admin.notifications.events': 'Eventos de notificação',
- 'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
- 'admin.notifications.configureFirst': 'Configure primeiro as configurações SMTP ou webhook abaixo, depois ative os eventos.',
'admin.notifications.save': 'Salvar configurações de notificação',
'admin.notifications.saved': 'Configurações de notificação salvas',
'admin.notifications.testWebhook': 'Enviar webhook de teste',
@@ -295,6 +303,7 @@ const br: Record = {
'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configuração do cliente',
'settings.mcp.clientConfigHint': 'Substitua por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
+ 'settings.mcp.clientConfigHintOAuth': 'Substitua e pelas credenciais exibidas no cliente OAuth 2.1 criado acima. O mcp-remote abrirá seu navegador para concluir a autorização na primeira conexão. O caminho para o npx pode precisar ser ajustado para seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
'settings.mcp.copy': 'Copiar',
'settings.mcp.copied': 'Copiado!',
'settings.mcp.apiTokens': 'Tokens de API',
@@ -316,6 +325,48 @@ const br: Record = {
'settings.mcp.toast.createError': 'Falha ao criar token',
'settings.mcp.toast.deleted': 'Token excluído',
'settings.mcp.toast.deleteError': 'Falha ao excluir token',
+ 'settings.mcp.apiTokensDeprecated': 'Os tokens de API estão obsoletos e serão removidos em uma versão futura. Por favor, use Clientes OAuth 2.1.',
+ 'settings.oauth.clients': 'Clientes OAuth 2.1',
+ 'settings.oauth.clientsHint': 'Registre clientes OAuth 2.1 para permitir que aplicações MCP de terceiros (Claude Web, Cursor, etc.) se conectem sem tokens estáticos.',
+ 'settings.oauth.createClient': 'Novo cliente',
+ 'settings.oauth.noClients': 'Nenhum cliente OAuth registrado.',
+ 'settings.oauth.clientId': 'ID do cliente',
+ 'settings.oauth.clientSecret': 'Segredo do cliente',
+ 'settings.oauth.deleteClient': 'Excluir cliente',
+ 'settings.oauth.deleteClientMessage': 'Este cliente e todas as sessões ativas serão removidos permanentemente. Qualquer aplicação que o utilize perderá o acesso imediatamente.',
+ 'settings.oauth.rotateSecret': 'Renovar segredo',
+ 'settings.oauth.rotateSecretMessage': 'Um novo segredo de cliente será gerado e todas as sessões existentes serão invalidadas imediatamente. Atualize sua aplicação antes de fechar esta janela.',
+ 'settings.oauth.rotateSecretConfirm': 'Renovar',
+ 'settings.oauth.rotateSecretConfirming': 'Renovando…',
+ 'settings.oauth.rotateSecretDoneTitle': 'Novo segredo gerado',
+ 'settings.oauth.rotateSecretDoneWarning': 'Este segredo é exibido apenas uma vez. Copie-o agora e atualize sua aplicação — todas as sessões anteriores foram invalidadas.',
+ 'settings.oauth.activeSessions': 'Sessões OAuth ativas',
+ 'settings.oauth.sessionScopes': 'Escopos',
+ 'settings.oauth.sessionExpires': 'Expira',
+ 'settings.oauth.revoke': 'Revogar',
+ 'settings.oauth.revokeSession': 'Revogar sessão',
+ 'settings.oauth.revokeSessionMessage': 'Isso revogará imediatamente o acesso desta sessão OAuth.',
+ 'settings.oauth.modal.createTitle': 'Registrar cliente OAuth',
+ 'settings.oauth.modal.presets': 'Configurações rápidas',
+ 'settings.oauth.modal.clientName': 'Nome da aplicação',
+ 'settings.oauth.modal.clientNamePlaceholder': 'ex.: Claude Web, Meu app MCP',
+ 'settings.oauth.modal.redirectUris': 'URIs de redirecionamento',
+ 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
+ 'settings.oauth.modal.redirectUrisHint': 'Uma URI por linha. HTTPS obrigatório (localhost isento). Correspondência exata.',
+ 'settings.oauth.modal.scopes': 'Escopos permitidos',
+ 'settings.oauth.modal.scopesHint': 'list_trips e get_trip_summary estão sempre disponíveis — sem escopo necessário. Permitem à IA descobrir IDs de viagem.',
+ 'settings.oauth.modal.selectAll': 'Selecionar tudo',
+ 'settings.oauth.modal.deselectAll': 'Desmarcar tudo',
+ 'settings.oauth.modal.creating': 'Registrando…',
+ 'settings.oauth.modal.create': 'Registrar cliente',
+ 'settings.oauth.modal.createdTitle': 'Cliente registrado',
+ 'settings.oauth.modal.createdWarning': 'O segredo do cliente é exibido apenas uma vez. Copie-o agora — não pode ser recuperado.',
+ 'settings.oauth.toast.createError': 'Falha ao registrar cliente OAuth',
+ 'settings.oauth.toast.deleted': 'Cliente OAuth excluído',
+ 'settings.oauth.toast.deleteError': 'Falha ao excluir cliente OAuth',
+ 'settings.oauth.toast.revoked': 'Sessão revogada',
+ 'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
+ 'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
// Login
@@ -371,6 +422,10 @@ const br: Record = {
'login.mfaHint': 'Abra o Google Authenticator, Authy ou outro app TOTP.',
'login.mfaBack': '← Voltar ao login',
'login.mfaVerify': 'Verificar',
+ 'login.invalidInviteLink': 'Link de convite inválido ou expirado',
+ 'login.oidcFailed': 'Falha no login OIDC',
+ 'login.usernameRequired': 'Nome de usuário é obrigatório',
+ 'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
// Register
'register.passwordMismatch': 'As senhas não coincidem',
@@ -449,6 +504,17 @@ const br: Record = {
'admin.tabs.settings': 'Configurações',
'admin.allowRegistration': 'Permitir cadastro',
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
+ 'admin.authMethods': 'Authentication Methods',
+ 'admin.passwordLogin': 'Password Login',
+ 'admin.passwordLoginHint': 'Allow users to sign in with email and password',
+ 'admin.passwordRegistration': 'Password Registration',
+ 'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
+ 'admin.oidcLogin': 'SSO Login',
+ 'admin.oidcLoginHint': 'Allow users to sign in with SSO',
+ 'admin.oidcRegistration': 'SSO Auto-Provisioning',
+ 'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
+ 'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
+ 'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
'admin.apiKeys': 'Chaves de API',
@@ -463,7 +529,7 @@ const br: Record = {
'admin.keyValid': 'Conectado',
'admin.keyInvalid': 'Inválida',
'admin.keySaved': 'Chaves de API salvas',
- 'admin.oidcTitle': 'Single Sign-On (OIDC)',
+ 'admin.oidcTitle': 'Login Único (OIDC)',
'admin.oidcSubtitle': 'Permitir login via provedores externos como Google, Apple, Authentik ou Keycloak.',
'admin.oidcDisplayName': 'Nome exibido',
'admin.oidcIssuer': 'URL do emissor',
@@ -513,7 +579,7 @@ const br: Record = {
'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem',
'admin.addons.catalog.documents.name': 'Documentos',
'admin.addons.catalog.documents.description': 'Armazene e gerencie documentos de viagem',
- 'admin.addons.catalog.vacay.name': 'Vacay',
+ 'admin.addons.catalog.vacay.name': 'Férias',
'admin.addons.catalog.vacay.description': 'Planejador de férias pessoal com visão em calendário',
'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
@@ -546,7 +612,7 @@ const br: Record = {
'admin.weather.requestsDesc': 'Grátis, sem chave de API',
'admin.weather.locationHint': 'O clima usa o primeiro lugar com coordenadas de cada dia. Se nenhum lugar estiver atribuído ao dia, qualquer lugar da lista serve como referência.',
- 'admin.tabs.audit': 'Audit',
+ 'admin.tabs.audit': 'Auditoria',
'admin.audit.subtitle': 'Eventos sensíveis de segurança e administração (backups, usuários, 2FA, configurações).',
'admin.audit.empty': 'Nenhum registro de auditoria.',
@@ -645,6 +711,8 @@ const br: Record = {
'vacay.companyHolidays': 'Feriados da empresa',
'vacay.companyHolidaysHint': 'Permitir marcar dias de feriado em toda a empresa',
'vacay.companyHolidaysNoDeduct': 'Feriados da empresa não contam como dias de férias.',
+ 'vacay.weekStart': 'Semana começa em',
+ 'vacay.weekStartHint': 'Escolha se a semana começa na segunda-feira ou no domingo',
'vacay.carryOver': 'Acúmulo',
'vacay.carryOverHint': 'Levar automaticamente os dias de férias restantes para o ano seguinte',
'vacay.sharing': 'Compartilhamento',
@@ -794,15 +862,29 @@ const br: Record = {
// Places Sidebar
'places.addPlace': 'Adicionar lugar/atividade',
- 'places.importGpx': 'GPX',
+ 'places.importFile': 'Importar arquivo',
+ 'places.sidebarDrop': 'Solte para importar',
+ 'places.importFileHint': 'Importe arquivos .gpx, .kml ou .kmz de ferramentas como Google My Maps, Google Earth ou um rastreador GPS.',
+ 'places.importFileDropHere': 'Clique para selecionar um arquivo ou arraste e solte aqui',
+ 'places.importFileDropActive': 'Solte o arquivo para selecionar',
+ 'places.importFileUnsupported': 'Tipo de arquivo não suportado. Use .gpx, .kml ou .kmz.',
+ 'places.importFileTooLarge': 'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.',
+ 'places.importFileError': 'Importação falhou',
+ 'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
'places.gpxImported': '{count} lugares importados do GPX',
- 'places.gpxError': 'Falha ao importar GPX',
+ 'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
+ 'places.urlResolved': 'Lugar importado da URL',
+ 'places.importList': 'Importar lista',
+ 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Ignorados: {skipped}',
'places.importGoogleList': 'Lista Google',
+ 'places.importNaverList': 'Lista Naver',
'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
'places.googleListImported': '{count} lugares importados de "{list}"',
'places.googleListError': 'Falha ao importar lista do Google Maps',
+ 'places.naverListHint': 'Cole um link compartilhado de uma lista do Naver Maps para importar todos os lugares.',
+ 'places.naverListImported': '{count} lugares importados de "{list}"',
+ 'places.naverListError': 'Falha ao importar lista do Naver Maps',
'places.viewDetails': 'Ver detalhes',
- 'places.urlResolved': 'Lugar importado da URL',
'places.assignToDay': 'Adicionar a qual dia?',
'places.all': 'Todos',
'places.unplanned': 'Não planejados',
@@ -832,11 +914,13 @@ const br: Record = {
'places.endTimeBeforeStart': 'O horário de fim é antes do início',
'places.timeCollision': 'Sobreposição de horário com:',
'places.formWebsite': 'Site',
+ 'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas pessoais...',
'places.formReservation': 'Reserva',
'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...',
'places.mapsSearchPlaceholder': 'Buscar lugares...',
'places.mapsSearchError': 'Falha na busca de lugares.',
+ 'places.loadingDetails': 'Carregando detalhes do lugar…',
'places.osmHint': 'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.',
'places.osmActive': 'Busca via OpenStreetMap (sem fotos, avaliações ou horário de funcionamento). Adicione uma chave Google em Configurações para mais dados.',
'places.categoryCreateError': 'Falha ao criar categoria',
@@ -850,6 +934,7 @@ const br: Record = {
'inspector.files': 'Arquivos',
'inspector.filesCount': '{count} arquivos',
'inspector.removeFromDay': 'Remover do dia',
+ 'inspector.remove': 'Remover',
'inspector.addToDay': 'Adicionar ao dia',
'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendente',
@@ -990,6 +1075,7 @@ const br: Record = {
'budget.totalBudget': 'Orçamento total',
'budget.byCategory': 'Por categoria',
'budget.editTooltip': 'Clique para editar',
+ 'budget.linkedToReservation': 'Vinculado a uma reserva — edite o nome por lá',
'budget.confirm.deleteCategory': 'Excluir a categoria "{name}" com {count} lançamento(s)?',
'budget.deleteCategory': 'Excluir categoria',
'budget.perPerson': 'Por pessoa',
@@ -1002,6 +1088,9 @@ const br: Record = {
// Files
'files.title': 'Arquivos',
+ 'files.pageTitle': 'Arquivos e documentos',
+ 'files.subtitle': '{count} arquivos para {trip}',
+ 'files.downloadPdf': 'Baixar PDF',
'files.count': '{count} arquivos',
'files.countSingular': '1 arquivo',
'files.uploaded': '{count} enviado(s)',
@@ -1070,6 +1159,9 @@ const br: Record = {
'packing.allPacked': 'Tudo na mala!',
'packing.addPlaceholder': 'Adicionar item...',
'packing.categoryPlaceholder': 'Categoria...',
+ 'packing.saveAsTemplate': 'Salvar como modelo',
+ 'packing.templateName': 'Nome do modelo',
+ 'packing.templateSaved': 'Lista de bagagem salva como modelo',
'packing.filterAll': 'Todos',
'packing.filterOpen': 'Abertos',
'packing.filterDone': 'Prontos',
@@ -1080,7 +1172,6 @@ const br: Record = {
'packing.menuCheckAll': 'Marcar todos',
'packing.menuUncheckAll': 'Desmarcar todos',
'packing.menuDeleteCat': 'Excluir categoria',
- 'packing.assignUser': 'Atribuir usuário',
'packing.noMembers': 'Nenhum membro na viagem',
'packing.addItem': 'Adicionar item',
'packing.addItemPlaceholder': 'Nome do item...',
@@ -1245,6 +1336,13 @@ const br: Record = {
'backup.keep.forever': 'Manter para sempre',
// Photos
+ 'photos.title': 'Fotos',
+ 'photos.subtitle': '{count} fotos para {trip}',
+ 'photos.dropHere': 'Arraste fotos aqui...',
+ 'photos.dropHereActive': 'Arraste fotos aqui',
+ 'photos.captionForAll': 'Legenda (para todos)',
+ 'photos.captionPlaceholder': 'Legenda opcional...',
+ 'photos.addCaption': 'Adicionar legenda...',
'photos.allDays': 'Todos os dias',
'photos.noPhotos': 'Nenhuma foto ainda',
'photos.uploadHint': 'Envie suas fotos de viagem',
@@ -1252,6 +1350,12 @@ const br: Record = {
'photos.linkPlace': 'Vincular lugar',
'photos.noPlace': 'Sem lugar',
'photos.uploadN': 'Enviar {n} foto(s)',
+ 'photos.linkDay': 'Vincular dia',
+ 'photos.noDay': 'Nenhum dia',
+ 'photos.dayLabel': 'Dia {number}',
+ 'photos.photoSelected': 'Foto selecionada',
+ 'photos.photosSelected': 'Fotos selecionadas',
+ 'photos.fileTypeHint': 'JPG, PNG, WebP · máx. 10 MB · até 30 fotos',
// Backup restore modal
'backup.restoreConfirmTitle': 'Restaurar backup?',
@@ -1278,6 +1382,7 @@ const br: Record = {
'planner.routeCalculated': 'Rota calculada',
'planner.routeCalcFailed': 'Não foi possível calcular a rota',
'planner.routeError': 'Erro ao calcular a rota',
+ 'planner.icsExportFailed': 'Falha ao exportar ICS',
'planner.routeOptimized': 'Rota otimizada',
'planner.reservationUpdated': 'Reserva atualizada',
'planner.reservationAdded': 'Reserva adicionada',
@@ -1433,6 +1538,7 @@ const br: Record = {
'memories.title': 'Fotos',
'memories.notConnected': 'Immich não conectado',
'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.',
+ 'memories.notConnectedMultipleHint': 'Conecte um destes provedores de fotos: {provider_names} nas Configurações para poder adicionar fotos a esta viagem.',
'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.',
'memories.noPhotos': 'Nenhuma foto encontrada',
'memories.noPhotosHint': 'Nenhuma foto encontrada no Immich para o período desta viagem.',
@@ -1443,23 +1549,32 @@ const br: Record = {
'memories.reviewTitle': 'Revise suas fotos',
'memories.reviewHint': 'Clique nas fotos para excluí-las do compartilhamento.',
'memories.shareCount': 'Compartilhar {count} fotos',
- 'memories.immichUrl': 'URL do servidor Immich',
- 'memories.immichApiKey': 'Chave da API',
+ 'memories.providerUrl': 'URL do servidor',
+ 'memories.providerApiKey': 'Chave de API',
+ 'memories.providerUsername': 'Nome de usuário',
+ 'memories.providerPassword': 'Senha',
+ 'memories.providerOTP': 'Código MFA (se habilitado)',
+ 'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
+ 'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão',
'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado',
'memories.connectionSuccess': 'Conectado ao Immich',
'memories.connectionError': 'Não foi possível conectar ao Immich',
- 'memories.saved': 'Configurações do Immich salvas',
+ 'memories.saved': 'Configurações do {provider_name} salvas',
+ 'memories.providerDisconnectedBanner': 'Sua conexão com {provider_name} foi perdida. Reconecte nas Configurações para ver as fotos.',
+ 'memories.saveError': 'Não foi possível salvar as configurações de {provider_name}',
'memories.addPhotos': 'Adicionar fotos',
'memories.linkAlbum': 'Vincular álbum',
'memories.selectAlbum': 'Selecionar álbum do Immich',
+ 'memories.selectAlbumMultiple': 'Selecionar álbum',
'memories.noAlbums': 'Nenhum álbum encontrado',
'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos',
'memories.selectPhotos': 'Selecionar fotos do Immich',
+ 'memories.selectPhotosMultiple': 'Selecionar fotos',
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
'memories.selected': 'selecionadas',
'memories.addSelected': 'Adicionar {count} fotos',
@@ -1477,9 +1592,10 @@ const br: Record = {
// Permissions
'admin.tabs.permissions': 'Permissões',
- 'admin.tabs.mcpTokens': 'Tokens MCP',
- 'admin.mcpTokens.title': 'Tokens MCP',
- 'admin.mcpTokens.subtitle': 'Gerenciar tokens de API de todos os usuários',
+ 'admin.tabs.mcpTokens': 'Acesso MCP',
+ 'admin.mcpTokens.title': 'Acesso MCP',
+ 'admin.mcpTokens.subtitle': 'Gerenciar sessões OAuth e tokens de API de todos os usuários',
+ 'admin.mcpTokens.sectionTitle': 'Tokens de API',
'admin.mcpTokens.owner': 'Proprietário',
'admin.mcpTokens.tokenName': 'Nome do Token',
'admin.mcpTokens.created': 'Criado',
@@ -1491,6 +1607,17 @@ const br: Record = {
'admin.mcpTokens.deleteSuccess': 'Token excluído',
'admin.mcpTokens.deleteError': 'Falha ao excluir token',
'admin.mcpTokens.loadError': 'Falha ao carregar tokens',
+ 'admin.oauthSessions.sectionTitle': 'Sessões OAuth',
+ 'admin.oauthSessions.clientName': 'Cliente',
+ 'admin.oauthSessions.owner': 'Proprietário',
+ 'admin.oauthSessions.scopes': 'Permissões',
+ 'admin.oauthSessions.created': 'Criado',
+ 'admin.oauthSessions.empty': 'Nenhuma sessão OAuth ativa',
+ 'admin.oauthSessions.revokeTitle': 'Revogar sessão',
+ 'admin.oauthSessions.revokeMessage': 'Esta sessão OAuth será revogada imediatamente. O cliente perderá o acesso MCP.',
+ 'admin.oauthSessions.revokeSuccess': 'Sessão revogada',
+ 'admin.oauthSessions.revokeError': 'Falha ao revogar sessão',
+ 'admin.oauthSessions.loadError': 'Falha ao carregar sessões OAuth',
'perm.title': 'Configurações de Permissões',
'perm.subtitle': 'Controle quem pode realizar ações no aplicativo',
'perm.saved': 'Configurações de permissões salvas',
@@ -1548,7 +1675,9 @@ const br: Record = {
'undo.moveDay': 'Local movido para outro dia',
'undo.lock': 'Bloqueio do local alternado',
'undo.importGpx': 'Importação de GPX',
+ 'undo.importKeyholeMarkup': 'Importação de KMZ/KML',
'undo.importGoogleList': 'Importação do Google Maps',
+ 'undo.importNaverList': 'Importação do Naver Maps',
// Notifications
'notifications.title': 'Notificações',
@@ -1563,6 +1692,8 @@ const br: Record = {
'notifications.markUnread': 'Marcar como não lido',
'notifications.delete': 'Excluir',
'notifications.system': 'Sistema',
+ 'notifications.synologySessionCleared.title': 'Synology Photos desconectado',
+ 'notifications.synologySessionCleared.text': 'Seu servidor ou conta foi alterado — vá para Configurações para testar sua conexão novamente.',
'memories.error.loadAlbums': 'Falha ao carregar álbuns',
'memories.error.linkAlbum': 'Falha ao vincular álbum',
'memories.error.unlinkAlbum': 'Falha ao desvincular álbum',
@@ -1685,6 +1816,324 @@ const br: Record = {
'notif.generic.text': 'Você tem uma nova notificação',
'notif.dev.unknown_event.title': '[DEV] Evento desconhecido',
'notif.dev.unknown_event.text': 'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG',
+
+ // Journey, Dashboard, Nav, DayPlan
+ 'common.justNow': 'agora mesmo',
+ 'common.hoursAgo': 'há {count}h',
+ 'common.daysAgo': 'há {count}d',
+ 'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
+ 'memories.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
+ 'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
+ 'journey.title': 'Jornada',
+ 'journey.subtitle': 'Registre suas viagens em tempo real',
+ 'journey.new': 'Nova jornada',
+ 'journey.create': 'Criar',
+ 'journey.titlePlaceholder': 'Para onde você vai?',
+ 'journey.empty': 'Nenhuma jornada ainda',
+ 'journey.emptyHint': 'Comece a documentar sua próxima viagem',
+ 'journey.deleted': 'Jornada excluída',
+ 'journey.createError': 'Não foi possível criar a jornada',
+ 'journey.deleteError': 'Não foi possível excluir a jornada',
+ 'journey.deleteConfirmTitle': 'Excluir',
+ 'journey.deleteConfirmMessage': 'Excluir "{title}"? Isso não pode ser desfeito.',
+ 'journey.deleteConfirmGeneric': 'Tem certeza de que deseja excluir isso?',
+ 'journey.notFound': 'Jornada não encontrada',
+ 'journey.photos': 'Fotos',
+ 'journey.timelineEmpty': 'Nenhuma parada ainda',
+ 'journey.timelineEmptyHint': 'Adicione um check-in ou escreva uma entrada no diário para começar',
+ 'journey.status.draft': 'Rascunho',
+ 'journey.status.active': 'Ativa',
+ 'journey.status.completed': 'Concluída',
+ 'journey.status.upcoming': 'Próxima',
+ 'journey.checkin.add': 'Fazer check-in',
+ 'journey.checkin.namePlaceholder': 'Nome do local',
+ 'journey.checkin.notesPlaceholder': 'Notas (opcional)',
+ 'journey.checkin.save': 'Salvar',
+ 'journey.checkin.error': 'Não foi possível salvar o check-in',
+ 'journey.entry.add': 'Diário',
+ 'journey.entry.edit': 'Editar entrada',
+ 'journey.entry.titlePlaceholder': 'Título (opcional)',
+ 'journey.entry.bodyPlaceholder': 'O que aconteceu hoje?',
+ 'journey.entry.save': 'Salvar',
+ 'journey.entry.error': 'Não foi possível salvar a entrada',
+ 'journey.photo.add': 'Foto',
+ 'journey.photo.uploadError': 'Falha no envio',
+ 'journey.share.share': 'Compartilhar',
+ 'journey.share.public': 'Público',
+ 'journey.share.linkCopied': 'Link público copiado',
+ 'journey.share.disabled': 'Compartilhamento público desativado',
+ 'journey.editor.titlePlaceholder': 'Dê um nome a este momento...',
+ 'journey.editor.bodyPlaceholder': 'Conte a história deste dia...',
+ 'journey.editor.placePlaceholder': 'Localização (opcional)',
+ 'journey.editor.tagsPlaceholder': 'Tags: joia escondida, melhor refeição, preciso voltar...',
+ 'journey.visibility.private': 'Privado',
+ 'journey.visibility.shared': 'Compartilhado',
+ 'journey.visibility.public': 'Público',
+ 'journey.emptyState.title': 'Sua história começa aqui',
+ 'journey.emptyState.subtitle': 'Faça check-in em um lugar ou escreva sua primeira entrada no diário',
+ 'journey.frontpage.subtitle': 'Transforme suas viagens em histórias que você nunca vai esquecer',
+ 'journey.frontpage.createJourney': 'Criar jornada',
+ 'journey.frontpage.activeJourney': 'Jornada ativa',
+ 'journey.frontpage.allJourneys': 'Todas as jornadas',
+ 'journey.frontpage.journeys': 'jornadas',
+ 'journey.frontpage.createNew': 'Criar uma nova jornada',
+ 'journey.frontpage.createNewSub': 'Escolha viagens, escreva histórias, compartilhe suas aventuras',
+ 'journey.frontpage.live': 'Ao vivo',
+ 'journey.frontpage.synced': 'Sincronizado',
+ 'journey.frontpage.continueWriting': 'Continuar escrevendo',
+ 'journey.frontpage.updated': 'Atualizado {time}',
+ 'journey.frontpage.suggestionLabel': 'A viagem acabou de terminar',
+ 'journey.frontpage.suggestionText': 'Transforme {title} em uma jornada',
+ 'journey.frontpage.dismiss': 'Dispensar',
+ 'journey.frontpage.journeyName': 'Nome da jornada',
+ 'journey.frontpage.namePlaceholder': 'ex. Sudeste Asiático 2026',
+ 'journey.frontpage.selectTrips': 'Selecionar viagens',
+ 'journey.frontpage.tripsSelected': 'viagens selecionadas',
+ 'journey.frontpage.trips': 'viagens',
+ 'journey.frontpage.placesImported': 'lugares serão importados',
+ 'journey.frontpage.places': 'lugares',
+ 'journey.detail.backToJourney': 'Voltar à jornada',
+ 'journey.detail.syncedWithTrips': 'Sincronizado com viagens',
+ 'journey.detail.addEntry': 'Adicionar entrada',
+ 'journey.detail.newEntry': 'Nova entrada',
+ 'journey.detail.editEntry': 'Editar entrada',
+ 'journey.detail.noEntries': 'Nenhuma entrada ainda',
+ 'journey.detail.noEntriesHint': 'Adicione uma viagem para começar com entradas preliminares',
+ 'journey.detail.noPhotos': 'Nenhuma foto ainda',
+ 'journey.detail.noPhotosHint': 'Envie fotos para as entradas ou explore sua biblioteca do Immich/Synology',
+ 'journey.detail.journeyStats': 'Estatísticas da jornada',
+ 'journey.detail.syncedTrips': 'Viagens sincronizadas',
+ 'journey.detail.noTripsLinked': 'Nenhuma viagem vinculada ainda',
+ 'journey.detail.contributors': 'Colaboradores',
+ 'journey.detail.readMore': 'Ler mais',
+ 'journey.detail.prosCons': 'Prós e contras',
+ 'journey.detail.photos': 'fotos',
+ 'journey.detail.day': 'Dia {number}',
+ 'journey.detail.places': 'lugares',
+ 'journey.stats.days': 'Dias',
+ 'journey.stats.cities': 'Cidades',
+ 'journey.stats.entries': 'Entradas',
+ 'journey.stats.photos': 'Fotos',
+ 'journey.stats.places': 'Lugares',
+ 'journey.skeletons.show': 'Mostrar sugestões',
+ 'journey.skeletons.hide': 'Ocultar sugestões',
+ 'journey.verdict.lovedIt': 'Adorei',
+ 'journey.verdict.couldBeBetter': 'Poderia ser melhor',
+ 'journey.synced.places': 'lugares',
+ 'journey.synced.synced': 'sincronizado',
+ 'journey.editor.uploadPhotos': 'Enviar fotos',
+ 'journey.editor.uploading': 'Enviando...',
+ 'journey.editor.fromGallery': 'Da galeria',
+ 'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
+ 'journey.editor.writeStory': 'Escreva sua história...',
+ 'journey.editor.prosCons': 'Prós e contras',
+ 'journey.editor.pros': 'Prós',
+ 'journey.editor.cons': 'Contras',
+ 'journey.editor.proPlaceholder': 'Algo ótimo...',
+ 'journey.editor.conPlaceholder': 'Não tão bom...',
+ 'journey.editor.addAnother': 'Adicionar outro',
+ 'journey.editor.date': 'Data',
+ 'journey.editor.location': 'Localização',
+ 'journey.editor.searchLocation': 'Buscar localização...',
+ 'journey.editor.mood': 'Humor',
+ 'journey.editor.weather': 'Clima',
+ 'journey.editor.photoFirst': '1º',
+ 'journey.editor.makeFirst': 'Tornar 1º',
+ 'journey.editor.searching': 'Pesquisando...',
+ 'journey.mood.amazing': 'Incrível',
+ 'journey.mood.good': 'Bom',
+ 'journey.mood.neutral': 'Neutro',
+ 'journey.mood.rough': 'Difícil',
+ 'journey.weather.sunny': 'Ensolarado',
+ 'journey.weather.partly': 'Parcialmente nublado',
+ 'journey.weather.cloudy': 'Nublado',
+ 'journey.weather.rainy': 'Chuvoso',
+ 'journey.weather.stormy': 'Tempestuoso',
+ 'journey.weather.cold': 'Nevando',
+ 'journey.trips.linkTrip': 'Vincular viagem',
+ 'journey.trips.searchTrip': 'Buscar viagem',
+ 'journey.trips.searchPlaceholder': 'Nome da viagem ou destino...',
+ 'journey.trips.noTripsAvailable': 'Nenhuma viagem disponível',
+ 'journey.trips.link': 'Vincular',
+ 'journey.trips.tripLinked': 'Viagem vinculada',
+ 'journey.trips.linkFailed': 'Não foi possível vincular a viagem',
+ 'journey.trips.addTrip': 'Adicionar viagem',
+ 'journey.trips.unlinkTrip': 'Desvincular viagem',
+ 'journey.trips.unlinkMessage': 'Desvincular "{title}"? Todas as entradas e fotos sincronizadas desta viagem serão excluídas permanentemente. Isso não pode ser desfeito.',
+ 'journey.trips.unlink': 'Desvincular',
+ 'journey.trips.tripUnlinked': 'Viagem desvinculada',
+ 'journey.trips.unlinkFailed': 'Não foi possível desvincular a viagem',
+ 'journey.trips.noTripsLinkedSettings': 'Nenhuma viagem vinculada',
+ 'journey.contributors.invite': 'Convidar colaborador',
+ 'journey.contributors.searchUser': 'Buscar usuário',
+ 'journey.contributors.searchPlaceholder': 'Nome de usuário ou e-mail...',
+ 'journey.contributors.noUsers': 'Nenhum usuário encontrado',
+ 'journey.contributors.role': 'Função',
+ 'journey.contributors.added': 'Colaborador adicionado',
+ 'journey.contributors.addFailed': 'Não foi possível adicionar o colaborador',
+ 'journey.share.publicShare': 'Compartilhamento público',
+ 'journey.share.createLink': 'Criar link de compartilhamento',
+ 'journey.share.linkCreated': 'Link de compartilhamento criado',
+ 'journey.share.createFailed': 'Não foi possível criar o link',
+ 'journey.share.copy': 'Copiar',
+ 'journey.share.copied': 'Copiado!',
+ 'journey.share.timeline': 'Linha do tempo',
+ 'journey.share.gallery': 'Galeria',
+ 'journey.share.map': 'Mapa',
+ 'journey.share.removeLink': 'Remover link de compartilhamento',
+ 'journey.share.linkDeleted': 'Link de compartilhamento removido',
+ 'journey.share.deleteFailed': 'Não foi possível excluir',
+ 'journey.share.updateFailed': 'Não foi possível atualizar',
+
+ // Journey — Invite
+ 'journey.invite.role': 'Função',
+ 'journey.invite.viewer': 'Visualizador',
+ 'journey.invite.editor': 'Editor',
+ 'journey.invite.invite': 'Convidar',
+ 'journey.invite.inviting': 'Convidando...',
+ 'journey.settings.title': 'Configurações da jornada',
+ 'journey.settings.coverImage': 'Imagem de capa',
+ 'journey.settings.changeCover': 'Alterar capa',
+ 'journey.settings.addCover': 'Adicionar imagem de capa',
+ 'journey.settings.name': 'Nome',
+ 'journey.settings.subtitle': 'Subtítulo',
+ 'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
+ 'journey.settings.delete': 'Excluir',
+ 'journey.settings.deleteJourney': 'Excluir jornada',
+ 'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
+ 'journey.settings.saved': 'Configurações salvas',
+ 'journey.settings.saveFailed': 'Não foi possível salvar',
+ 'journey.settings.coverUpdated': 'Capa atualizada',
+ 'journey.settings.coverFailed': 'Falha no envio',
+ 'journey.settings.failedToDelete': 'Falha ao excluir',
+ 'journey.entries.deleteTitle': 'Excluir entrada',
+ 'journey.photosUploaded': '{count} fotos enviadas',
+ 'journey.photosAdded': '{count} fotos adicionadas',
+ 'journey.public.notFound': 'Não encontrado',
+ 'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
+ 'journey.public.readOnly': 'Somente leitura · Jornada pública',
+ 'journey.public.tagline': 'Kit de recursos e exploração de viagens',
+ 'journey.public.sharedVia': 'Compartilhado via',
+ 'journey.public.madeWith': 'Feito com',
+ 'journey.pdf.journeyBook': 'Livro da jornada',
+ 'journey.pdf.madeWith': 'Feito com TREK',
+ 'journey.pdf.day': 'Dia',
+ 'journey.pdf.theEnd': 'Fim',
+ 'journey.pdf.saveAsPdf': 'Salvar como PDF',
+ 'journey.pdf.pages': 'páginas',
+ 'journey.picker.tripPeriod': 'Período da viagem',
+ 'journey.picker.dateRange': 'Período',
+ 'journey.picker.allPhotos': 'Todas as fotos',
+ 'journey.picker.albums': 'Álbuns',
+ 'journey.picker.selected': 'selecionados',
+ 'journey.picker.addTo': 'Adicionar a',
+ 'journey.picker.newGallery': 'Nova galeria',
+ 'journey.picker.selectAll': 'Selecionar tudo',
+ 'journey.picker.deselectAll': 'Desmarcar tudo',
+ 'journey.picker.noAlbums': 'Nenhum álbum encontrado',
+ 'journey.picker.selectDate': 'Selecionar data',
+ 'journey.picker.search': 'Pesquisar',
+ 'dashboard.greeting.morning': 'Bom dia,',
+ 'dashboard.greeting.afternoon': 'Boa tarde,',
+ 'dashboard.greeting.evening': 'Boa noite,',
+ 'dashboard.mobile.liveNow': 'Ao vivo agora',
+ 'dashboard.mobile.tripProgress': 'Progresso da viagem',
+ 'dashboard.mobile.daysLeft': '{count} dias restantes',
+ 'dashboard.mobile.places': 'Lugares',
+ 'dashboard.mobile.buddies': 'Companheiros',
+ 'dashboard.mobile.newTrip': 'Nova viagem',
+ 'dashboard.mobile.currency': 'Moeda',
+ 'dashboard.mobile.timezone': 'Fuso horário',
+ 'dashboard.mobile.upcomingTrips': 'Próximas viagens',
+ 'dashboard.mobile.yourTrips': 'Suas viagens',
+ 'dashboard.mobile.trips': 'viagens',
+ 'dashboard.mobile.starts': 'Começa',
+ 'dashboard.mobile.duration': 'Duração',
+ 'dashboard.mobile.day': 'dia',
+ 'dashboard.mobile.days': 'dias',
+ 'dashboard.mobile.ongoing': 'Em andamento',
+ 'dashboard.mobile.startsToday': 'Começa hoje',
+ 'dashboard.mobile.tomorrow': 'Amanhã',
+ 'dashboard.mobile.inDays': 'Em {count} dias',
+ 'dashboard.mobile.inMonths': 'Em {count} meses',
+ 'dashboard.mobile.completed': 'Concluído',
+ 'dashboard.mobile.currencyConverter': 'Conversor de moedas',
+ 'nav.profile': 'Perfil',
+ 'nav.bottomSettings': 'Configurações',
+ 'nav.bottomAdmin': 'Administração',
+ 'nav.bottomLogout': 'Sair',
+ 'nav.bottomAdminBadge': 'Admin',
+ 'dayplan.mobile.addPlace': 'Adicionar lugar',
+ 'dayplan.mobile.searchPlaces': 'Buscar lugares...',
+ 'dayplan.mobile.allAssigned': 'Todos os lugares atribuídos',
+ 'dayplan.mobile.noMatch': 'Sem correspondência',
+ 'dayplan.mobile.createNew': 'Criar novo lugar',
+ 'admin.addons.catalog.journey.name': 'Jornada',
+ 'admin.addons.catalog.journey.description': 'Rastreamento de viagens e diário de viajante com check-ins, fotos e histórias diárias',
+
+ // OAuth scope groups
+ 'oauth.scope.group.trips': 'Viagens',
+ 'oauth.scope.group.places': 'Locais',
+ 'oauth.scope.group.atlas': 'Atlas',
+ 'oauth.scope.group.packing': 'Bagagem',
+ 'oauth.scope.group.todos': 'Tarefas',
+ 'oauth.scope.group.budget': 'Orçamento',
+ 'oauth.scope.group.reservations': 'Reservas',
+ 'oauth.scope.group.collab': 'Colaboração',
+ 'oauth.scope.group.notifications': 'Notificações',
+ 'oauth.scope.group.vacay': 'Férias',
+ 'oauth.scope.group.geo': 'Geo',
+ 'oauth.scope.group.weather': 'Clima',
+
+ // OAuth scope labels & descriptions
+ 'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
+ 'oauth.scope.trips:read.description': 'Ler viagens, dias, notas e membros',
+ 'oauth.scope.trips:write.label': 'Editar viagens e itinerários',
+ 'oauth.scope.trips:write.description': 'Criar e atualizar viagens, dias, notas e gerenciar membros',
+ 'oauth.scope.trips:delete.label': 'Excluir viagens',
+ 'oauth.scope.trips:delete.description': 'Excluir viagens permanentemente — esta ação é irreversível',
+ 'oauth.scope.trips:share.label': 'Gerenciar links de compartilhamento',
+ 'oauth.scope.trips:share.description': 'Criar, atualizar e revogar links de compartilhamento públicos',
+ 'oauth.scope.places:read.label': 'Ver locais e dados do mapa',
+ 'oauth.scope.places:read.description': 'Ler locais, atribuições de dias, tags e categorias',
+ 'oauth.scope.places:write.label': 'Gerenciar locais',
+ 'oauth.scope.places:write.description': 'Criar, atualizar e excluir locais, atribuições e tags',
+ 'oauth.scope.atlas:read.label': 'Ver Atlas',
+ 'oauth.scope.atlas:read.description': 'Ler países visitados, regiões e lista de desejos',
+ 'oauth.scope.atlas:write.label': 'Gerenciar Atlas',
+ 'oauth.scope.atlas:write.description': 'Marcar países e regiões como visitados, gerenciar lista de desejos',
+ 'oauth.scope.packing:read.label': 'Ver listas de bagagem',
+ 'oauth.scope.packing:read.description': 'Ler itens, malas e responsáveis por categoria',
+ 'oauth.scope.packing:write.label': 'Gerenciar listas de bagagem',
+ 'oauth.scope.packing:write.description': 'Adicionar, atualizar, excluir, marcar e reordenar itens e malas',
+ 'oauth.scope.todos:read.label': 'Ver listas de tarefas',
+ 'oauth.scope.todos:read.description': 'Ler tarefas da viagem e responsáveis por categoria',
+ 'oauth.scope.todos:write.label': 'Gerenciar listas de tarefas',
+ 'oauth.scope.todos:write.description': 'Criar, atualizar, marcar, excluir e reordenar tarefas',
+ 'oauth.scope.budget:read.label': 'Ver orçamento',
+ 'oauth.scope.budget:read.description': 'Ler itens de orçamento e detalhamento de despesas',
+ 'oauth.scope.budget:write.label': 'Gerenciar orçamento',
+ 'oauth.scope.budget:write.description': 'Criar, atualizar e excluir itens de orçamento',
+ 'oauth.scope.reservations:read.label': 'Ver reservas',
+ 'oauth.scope.reservations:read.description': 'Ler reservas e detalhes de acomodação',
+ 'oauth.scope.reservations:write.label': 'Gerenciar reservas',
+ 'oauth.scope.reservations:write.description': 'Criar, atualizar, excluir e reordenar reservas',
+ 'oauth.scope.collab:read.label': 'Ver colaboração',
+ 'oauth.scope.collab:read.description': 'Ler notas colaborativas, enquetes e mensagens',
+ 'oauth.scope.collab:write.label': 'Gerenciar colaboração',
+ 'oauth.scope.collab:write.description': 'Criar, atualizar e excluir notas, enquetes e mensagens',
+ 'oauth.scope.notifications:read.label': 'Ver notificações',
+ 'oauth.scope.notifications:read.description': 'Ler notificações e contagens não lidas',
+ 'oauth.scope.notifications:write.label': 'Gerenciar notificações',
+ 'oauth.scope.notifications:write.description': 'Marcar notificações como lidas e respondê-las',
+ 'oauth.scope.vacay:read.label': 'Ver planos de férias',
+ 'oauth.scope.vacay:read.description': 'Ler dados de planejamento de férias, entradas e estatísticas',
+ 'oauth.scope.vacay:write.label': 'Gerenciar planos de férias',
+ 'oauth.scope.vacay:write.description': 'Criar e gerenciar entradas de férias, feriados e planos de equipe',
+ 'oauth.scope.geo:read.label': 'Mapas e geocodificação',
+ 'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
+ 'oauth.scope.weather:read.label': 'Previsão do tempo',
+ 'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
}
export default br
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index 130f9623..283fb7e8 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -1,6 +1,8 @@
const cs: Record = {
// Společné (Common)
'common.save': 'Uložit',
+ 'common.showMore': 'Zobrazit více',
+ 'common.showLess': 'Zobrazit méně',
'common.cancel': 'Zrušit',
'common.delete': 'Smazat',
'common.edit': 'Upravit',
@@ -8,6 +10,8 @@ const cs: Record = {
'common.loading': 'Načítání...',
'common.import': 'Importovat',
'common.error': 'Chyba',
+ 'common.unknownError': 'Neznámá chyba',
+ 'common.tooManyAttempts': 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
'common.back': 'Zpět',
'common.all': 'Vše',
'common.close': 'Zavřít',
@@ -26,6 +30,12 @@ const cs: Record = {
'common.email': 'E-mail',
'common.password': 'Heslo',
'common.saving': 'Ukládání...',
+ 'trips.memberRemoved': '{username} odebrán',
+ 'trips.memberRemoveError': 'Odebrání se nezdařilo',
+ 'trips.memberAdded': '{username} přidán',
+ 'trips.memberAddError': 'Přidání se nezdařilo',
+ 'common.expand': 'Rozbalit',
+ 'common.collapse': 'Sbalit',
'common.saved': 'Uloženo',
'trips.reminder': 'Připomínka',
'trips.reminderNone': 'Žádná',
@@ -135,6 +145,7 @@ const cs: Record = {
'settings.tabs.notifications': 'Oznámení',
'settings.tabs.integrations': 'Integrace',
'settings.tabs.account': 'Účet',
+ 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'O aplikaci',
'settings.map': 'Mapy',
'settings.mapTemplate': 'Šablona mapy',
@@ -181,6 +192,7 @@ const cs: Record = {
'settings.mcp.endpoint': 'MCP endpoint',
'settings.mcp.clientConfig': 'Konfigurace klienta',
'settings.mcp.clientConfigHint': 'Nahraďte API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
+ 'settings.mcp.clientConfigHintOAuth': 'Nahraďte a přihlašovacími údaji ze klienta OAuth 2.1, který jste vytvořili výše. mcp-remote při prvním připojení otevře prohlížeč pro dokončení autorizace. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
'settings.mcp.copy': 'Kopírovat',
'settings.mcp.copied': 'Zkopírováno!',
'settings.mcp.apiTokens': 'API tokeny',
@@ -202,6 +214,48 @@ const cs: Record = {
'settings.mcp.toast.createError': 'Nepodařilo se vytvořit token',
'settings.mcp.toast.deleted': 'Token smazán',
'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token',
+ 'settings.mcp.apiTokensDeprecated': 'API tokeny jsou zastaralé a budou odstraněny v budoucí verzi. Místo toho použijte klienty OAuth 2.1.',
+ 'settings.oauth.clients': 'Klienti OAuth 2.1',
+ 'settings.oauth.clientsHint': 'Zaregistrujte klienty OAuth 2.1, aby se aplikace MCP třetích stran (Claude Web, Cursor atd.) mohly připojit bez statických tokenů.',
+ 'settings.oauth.createClient': 'Nový klient',
+ 'settings.oauth.noClients': 'Žádní klienti OAuth nejsou zaregistrováni.',
+ 'settings.oauth.clientId': 'ID klienta',
+ 'settings.oauth.clientSecret': 'Tajný klíč klienta',
+ 'settings.oauth.deleteClient': 'Smazat klienta',
+ 'settings.oauth.deleteClientMessage': 'Tento klient a všechny aktivní relace budou trvale odstraněny. Jakákoliv aplikace, která ho používá, okamžitě ztratí přístup.',
+ 'settings.oauth.rotateSecret': 'Obnovit tajný klíč',
+ 'settings.oauth.rotateSecretMessage': 'Bude vygenerován nový tajný klíč klienta a všechny stávající relace budou okamžitě zneplatněny. Aktualizujte aplikaci před zavřením tohoto dialogu.',
+ 'settings.oauth.rotateSecretConfirm': 'Obnovit',
+ 'settings.oauth.rotateSecretConfirming': 'Obnovování…',
+ 'settings.oauth.rotateSecretDoneTitle': 'Nový tajný klíč vygenerován',
+ 'settings.oauth.rotateSecretDoneWarning': 'Tento tajný klíč se zobrazí pouze jednou. Zkopírujte ho nyní a aktualizujte aplikaci — všechny předchozí relace byly zneplatněny.',
+ 'settings.oauth.activeSessions': 'Aktivní relace OAuth',
+ 'settings.oauth.sessionScopes': 'Oprávnění',
+ 'settings.oauth.sessionExpires': 'Vyprší',
+ 'settings.oauth.revoke': 'Odvolat',
+ 'settings.oauth.revokeSession': 'Odvolat relaci',
+ 'settings.oauth.revokeSessionMessage': 'Tím se okamžitě odvolá přístup pro tuto relaci OAuth.',
+ 'settings.oauth.modal.createTitle': 'Zaregistrovat klienta OAuth',
+ 'settings.oauth.modal.presets': 'Rychlá nastavení',
+ 'settings.oauth.modal.clientName': 'Název aplikace',
+ 'settings.oauth.modal.clientNamePlaceholder': 'např. Claude Web, Moje MCP aplikace',
+ 'settings.oauth.modal.redirectUris': 'Přesměrovací URI',
+ 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
+ 'settings.oauth.modal.redirectUrisHint': 'Jedno URI na řádek. Vyžadováno HTTPS (localhost vyjmuto). Vyžadována přesná shoda.',
+ 'settings.oauth.modal.scopes': 'Povolená oprávnění',
+ 'settings.oauth.modal.scopesHint': 'list_trips a get_trip_summary jsou vždy dostupné — bez požadovaného oprávnění. Umožňují AI zjistit potřebná ID výletů.',
+ 'settings.oauth.modal.selectAll': 'Vybrat vše',
+ 'settings.oauth.modal.deselectAll': 'Zrušit výběr',
+ 'settings.oauth.modal.creating': 'Registrování…',
+ 'settings.oauth.modal.create': 'Zaregistrovat klienta',
+ 'settings.oauth.modal.createdTitle': 'Klient zaregistrován',
+ 'settings.oauth.modal.createdWarning': 'Tajný klíč klienta se zobrazí pouze jednou. Zkopírujte ho nyní — nelze ho obnovit.',
+ 'settings.oauth.toast.createError': 'Registrace klienta OAuth se nezdařila',
+ 'settings.oauth.toast.deleted': 'Klient OAuth smazán',
+ 'settings.oauth.toast.deleteError': 'Smazání klienta OAuth se nezdařilo',
+ 'settings.oauth.toast.revoked': 'Relace odvolána',
+ 'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
+ 'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
'settings.account': 'Účet',
'settings.about': 'O aplikaci',
'settings.about.reportBug': 'Nahlásit chybu',
@@ -274,9 +328,6 @@ const cs: Record = {
'admin.notifications.none': 'Vypnuto',
'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
- 'admin.notifications.events': 'Události oznámení',
- 'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
- 'admin.notifications.configureFirst': 'Nejprve nakonfigurujte nastavení SMTP nebo webhooku níže, poté povolte události.',
'admin.notifications.save': 'Uložit nastavení oznámení',
'admin.notifications.saved': 'Nastavení oznámení uloženo',
'admin.notifications.testWebhook': 'Odeslat testovací webhook',
@@ -371,6 +422,10 @@ const cs: Record = {
'login.mfaHint': 'Otevřete Google Authenticator, Authy nebo jinou TOTP aplikaci.',
'login.mfaBack': '← Zpět k přihlášení',
'login.mfaVerify': 'Ověřit',
+ 'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou',
+ 'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
+ 'login.usernameRequired': 'Uživatelské jméno je povinné',
+ 'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
// Registrace (Register)
'register.passwordMismatch': 'Hesla se neshodují',
@@ -449,6 +504,17 @@ const cs: Record = {
'admin.tabs.settings': 'Nastavení',
'admin.allowRegistration': 'Povolit registraci',
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
+ 'admin.authMethods': 'Authentication Methods',
+ 'admin.passwordLogin': 'Password Login',
+ 'admin.passwordLoginHint': 'Allow users to sign in with email and password',
+ 'admin.passwordRegistration': 'Password Registration',
+ 'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
+ 'admin.oidcLogin': 'SSO Login',
+ 'admin.oidcLoginHint': 'Allow users to sign in with SSO',
+ 'admin.oidcRegistration': 'SSO Auto-Provisioning',
+ 'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
+ 'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
+ 'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)',
'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.',
'admin.apiKeys': 'API klíče',
@@ -550,9 +616,10 @@ const cs: Record = {
'admin.audit.col.details': 'Detaily',
// MCP Tokens
- 'admin.tabs.mcpTokens': 'MCP tokeny',
- 'admin.mcpTokens.title': 'MCP tokeny',
- 'admin.mcpTokens.subtitle': 'Správa API tokenů všech uživatelů',
+ 'admin.tabs.mcpTokens': 'MCP přístup',
+ 'admin.mcpTokens.title': 'MCP přístup',
+ 'admin.mcpTokens.subtitle': 'Správa OAuth relací a API tokenů všech uživatelů',
+ 'admin.mcpTokens.sectionTitle': 'API tokeny',
'admin.mcpTokens.owner': 'Vlastník',
'admin.mcpTokens.tokenName': 'Název tokenu',
'admin.mcpTokens.created': 'Vytvořen',
@@ -564,6 +631,17 @@ const cs: Record = {
'admin.mcpTokens.deleteSuccess': 'Token smazán',
'admin.mcpTokens.deleteError': 'Nepodařilo se smazat token',
'admin.mcpTokens.loadError': 'Nepodařilo se načíst tokeny',
+ 'admin.oauthSessions.sectionTitle': 'OAuth relace',
+ 'admin.oauthSessions.clientName': 'Klient',
+ 'admin.oauthSessions.owner': 'Vlastník',
+ 'admin.oauthSessions.scopes': 'Oprávnění',
+ 'admin.oauthSessions.created': 'Vytvořeno',
+ 'admin.oauthSessions.empty': 'Žádné aktivní OAuth relace',
+ 'admin.oauthSessions.revokeTitle': 'Zrušit relaci',
+ 'admin.oauthSessions.revokeMessage': 'Tato OAuth relace bude okamžitě zrušena. Klient ztratí přístup k MCP.',
+ 'admin.oauthSessions.revokeSuccess': 'Relace zrušena',
+ 'admin.oauthSessions.revokeError': 'Nepodařilo se zrušit relaci',
+ 'admin.oauthSessions.loadError': 'Nepodařilo se načíst OAuth relace',
// GitHub
'admin.tabs.github': 'GitHub',
@@ -663,6 +741,8 @@ const cs: Record = {
'vacay.companyHolidays': 'Firemní volno',
'vacay.companyHolidaysHint': 'Povolit označování dnů celofiremního volna',
'vacay.companyHolidaysNoDeduct': 'Firemní volno se nezapočítává do nároku na dovolenou.',
+ 'vacay.weekStart': 'Týden začíná',
+ 'vacay.weekStartHint': 'Zvolte, zda týden začíná v pondělí nebo v neděli',
'vacay.carryOver': 'Převod dovolené',
'vacay.carryOverHint': 'Automaticky převádět zbývající dny do dalšího roku',
'vacay.sharing': 'Sdílení',
@@ -810,14 +890,28 @@ const cs: Record = {
// Boční panel míst (Places Sidebar)
'places.addPlace': 'Přidat místo/aktivitu',
- 'places.importGpx': 'GPX',
+ 'places.importFile': 'Importovat soubor',
+ 'places.sidebarDrop': 'Pusťte pro import',
+ 'places.importFileHint': 'Importujte soubory .gpx, .kml nebo .kmz z nástrojů jako Google My Maps, Google Earth nebo GPS tracker.',
+ 'places.importFileDropHere': 'Klikněte pro výběr souboru nebo jej přetáhněte sem',
+ 'places.importFileDropActive': 'Přetáhněte soubor pro výběr',
+ 'places.importFileUnsupported': 'Nepodporovaný typ souboru. Použijte .gpx, .kml nebo .kmz.',
+ 'places.importFileTooLarge': 'Soubor je příliš velký. Maximální velikost nahrání je {maxMb} MB.',
+ 'places.importFileError': 'Import se nezdařil',
+ 'places.importAllSkipped': 'Všechna místa již byla v cestě.',
'places.gpxImported': '{count} míst importováno z GPX',
+ 'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML',
'places.urlResolved': 'Místo importováno z URL',
- 'places.gpxError': 'Import GPX se nezdařil',
+ 'places.importList': 'Import seznamu',
+ 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importováno: {created} • Přeskočeno: {skipped}',
'places.importGoogleList': 'Google Seznam',
+ 'places.importNaverList': 'Naver Seznam',
'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.',
'places.googleListImported': '{count} míst importováno ze seznamu "{list}"',
'places.googleListError': 'Import seznamu Google Maps se nezdařil',
+ 'places.naverListHint': 'Vložte sdílený odkaz na seznam Naver Maps pro import všech míst.',
+ 'places.naverListImported': '{count} míst importováno ze seznamu "{list}"',
+ 'places.naverListError': 'Import seznamu Naver Maps se nezdařil',
'places.viewDetails': 'Zobrazit detaily',
'places.assignToDay': 'Přidat do kterého dne?',
'places.all': 'Vše',
@@ -848,11 +942,13 @@ const cs: Record = {
'places.endTimeBeforeStart': 'Čas konce je před časem začátku',
'places.timeCollision': 'Časový překryv s:',
'places.formWebsite': 'Webové stránky',
+ 'places.formNotes': 'Poznámky',
'places.formNotesPlaceholder': 'Osobní poznámky...',
'places.formReservation': 'Rezervace',
'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...',
'places.mapsSearchPlaceholder': 'Hledat místa...',
'places.mapsSearchError': 'Hledání místa se nezdařilo.',
+ 'places.loadingDetails': 'Načítání podrobností místa…',
'places.osmHint': 'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.',
'places.osmActive': 'Hledání přes OpenStreetMap.',
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
@@ -867,6 +963,7 @@ const cs: Record = {
'inspector.files': 'Soubory',
'inspector.filesCount': '{count} souborů',
'inspector.removeFromDay': 'Odebrat ze dne',
+ 'inspector.remove': 'Odstranit',
'inspector.addToDay': 'Přidat ke dni',
'inspector.confirmedRes': 'Potvrzená rezervace',
'inspector.pendingRes': 'Čekající rezervace',
@@ -1007,6 +1104,7 @@ const cs: Record = {
'budget.totalBudget': 'Celkový rozpočet',
'budget.byCategory': 'Podle kategorie',
'budget.editTooltip': 'Klikněte pro úpravu',
+ 'budget.linkedToReservation': 'Propojeno s rezervací — název upravte tam',
'budget.confirm.deleteCategory': 'Opravdu chcete smazat kategorii „{name}” s {count} položkami?',
'budget.deleteCategory': 'Smazat kategorii',
'budget.perPerson': 'Na osobu',
@@ -1019,6 +1117,9 @@ const cs: Record = {
// Soubory (Files)
'files.title': 'Soubory',
+ 'files.pageTitle': 'Soubory a dokumenty',
+ 'files.subtitle': '{count} souborů pro {trip}',
+ 'files.downloadPdf': 'Stáhnout PDF',
'files.count': '{count} souborů',
'files.countSingular': '1 soubor',
'files.uploaded': '{count} nahráno',
@@ -1097,7 +1198,6 @@ const cs: Record = {
'packing.menuCheckAll': 'Označit vše',
'packing.menuUncheckAll': 'Odznačit vše',
'packing.menuDeleteCat': 'Smazat kategorii',
- 'packing.assignUser': 'Přiřadit uživateli',
'packing.noMembers': 'Žádní členové cesty',
'packing.addItem': 'Přidat položku',
'packing.addItemPlaceholder': 'Název položky...',
@@ -1107,6 +1207,9 @@ const cs: Record = {
'packing.template': 'Šablona',
'packing.templateApplied': '{count} položek přidáno ze šablony',
'packing.templateError': 'Šablonu se nepodařilo použít',
+ 'packing.saveAsTemplate': 'Uložit jako šablonu',
+ 'packing.templateName': 'Název šablony',
+ 'packing.templateSaved': 'Seznam balení uložen jako šablona',
'packing.bags': 'Zavazadla',
'packing.noBag': 'Nepřiřazeno',
'packing.totalWeight': 'Celková váha',
@@ -1262,6 +1365,13 @@ const cs: Record = {
'backup.keep.forever': 'Uchovávat navždy',
// Fotky
+ 'photos.title': 'Fotografie',
+ 'photos.subtitle': '{count} fotek pro {trip}',
+ 'photos.dropHere': 'Přetáhněte fotografie sem...',
+ 'photos.dropHereActive': 'Přetáhněte fotografie sem',
+ 'photos.captionForAll': 'Popisek (pro všechny)',
+ 'photos.captionPlaceholder': 'Volitelný popisek...',
+ 'photos.addCaption': 'Přidat popisek...',
'photos.allDays': 'Všechny dny',
'photos.noPhotos': 'Zatím žádné fotky',
'photos.uploadHint': 'Nahrajte své cestovní fotky',
@@ -1269,6 +1379,12 @@ const cs: Record = {
'photos.linkPlace': 'Propojit s místem',
'photos.noPlace': 'Žádné místo',
'photos.uploadN': 'Nahrát {n} fotek',
+ 'photos.linkDay': 'Propojit den',
+ 'photos.noDay': 'Žádný den',
+ 'photos.dayLabel': 'Den {number}',
+ 'photos.photoSelected': 'Fotografie vybrána',
+ 'photos.photosSelected': 'Fotografie vybrány',
+ 'photos.fileTypeHint': 'JPG, PNG, WebP · max. 10 MB · až 30 fotografií',
// Obnovení zálohy
'backup.restoreConfirmTitle': 'Obnovit zálohu?',
@@ -1295,6 +1411,7 @@ const cs: Record = {
'planner.routeCalculated': 'Trasa vypočtena',
'planner.routeCalcFailed': 'Trasu se nepodařilo vypočítat',
'planner.routeError': 'Chyba při výpočtu trasy',
+ 'planner.icsExportFailed': 'Export ICS se nezdařil',
'planner.routeOptimized': 'Trasa optimalizována',
'planner.reservationUpdated': 'Rezervace aktualizována',
'planner.reservationAdded': 'Rezervace přidána',
@@ -1380,6 +1497,7 @@ const cs: Record = {
'memories.title': 'Fotky',
'memories.notConnected': 'Immich není připojen',
'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.',
+ 'memories.notConnectedMultipleHint': 'Pro přidání fotek k tomuto výletu připojte v Nastavení jednoho z těchto poskytovatelů fotek: {provider_names}.',
'memories.noDates': 'Přidejte data k cestě pro načtení fotek.',
'memories.noPhotos': 'Nenalezeny žádné fotky',
'memories.noPhotosHint': 'V Immich nebyly nalezeny žádné fotky pro období této cesty.',
@@ -1390,23 +1508,32 @@ const cs: Record = {
'memories.reviewTitle': 'Zkontrolujte své fotky',
'memories.reviewHint': 'Klikněte na fotky pro vyloučení ze sdílení.',
'memories.shareCount': 'Sdílet {count} fotek',
- 'memories.immichUrl': 'URL serveru Immich',
- 'memories.immichApiKey': 'API klíč',
+ 'memories.providerUrl': 'URL serveru',
+ 'memories.providerApiKey': 'API klíč',
+ 'memories.providerUsername': 'Uživatelské jméno',
+ 'memories.providerPassword': 'Heslo',
+ 'memories.providerOTP': 'MFA kód (pokud je povoleno)',
+ 'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
+ 'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
'memories.testConnection': 'Otestovat připojení',
'memories.testFirst': 'Nejprve otestujte připojení',
'memories.connected': 'Připojeno',
'memories.disconnected': 'Nepřipojeno',
'memories.connectionSuccess': 'Připojeno k Immich',
'memories.connectionError': 'Nepodařilo se připojit k Immich',
- 'memories.saved': 'Nastavení Immich uloženo',
+ 'memories.saved': 'Nastavení {provider_name} uloženo',
+ 'memories.providerDisconnectedBanner': 'Vaše připojení k {provider_name} bylo ztraceno. Obnovte připojení v Nastavení pro zobrazení fotek.',
+ 'memories.saveError': 'Nepodařilo se uložit nastavení {provider_name}',
'memories.addPhotos': 'Přidat fotky',
'memories.linkAlbum': 'Propojit album',
'memories.selectAlbum': 'Vybrat album z Immich',
+ 'memories.selectAlbumMultiple': 'Vybrat album',
'memories.noAlbums': 'Žádná alba nenalezena',
'memories.syncAlbum': 'Synchronizovat album',
'memories.unlinkAlbum': 'Odpojit',
'memories.photos': 'fotek',
'memories.selectPhotos': 'Vybrat fotky z Immich',
+ 'memories.selectPhotosMultiple': 'Vybrat fotky',
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
'memories.selected': 'vybráno',
'memories.addSelected': 'Přidat {count} fotek',
@@ -1551,7 +1678,9 @@ const cs: Record = {
'undo.moveDay': 'Místo přesunuto na jiný den',
'undo.lock': 'Zámek místa přepnut',
'undo.importGpx': 'Import GPX',
+ 'undo.importKeyholeMarkup': 'Import KMZ/KML',
'undo.importGoogleList': 'Import z Google Maps',
+ 'undo.importNaverList': 'Import z Naver Maps',
// Notifications
'notifications.title': 'Oznámení',
@@ -1566,6 +1695,8 @@ const cs: Record = {
'notifications.markUnread': 'Označit jako nepřečtené',
'notifications.delete': 'Smazat',
'notifications.system': 'Systém',
+ 'notifications.synologySessionCleared.title': 'Synology Photos odpojeno',
+ 'notifications.synologySessionCleared.text': 'Váš server nebo účet se změnil — přejděte do Nastavení a znovu otestujte připojení.',
'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.',
'atlas.searchCountry': 'Hledat zemi...',
'memories.error.loadAlbums': 'Načtení alb se nezdařilo',
@@ -1690,6 +1821,323 @@ const cs: Record = {
'notif.generic.text': 'Máte nové oznámení',
'notif.dev.unknown_event.title': '[DEV] Neznámá událost',
'notif.dev.unknown_event.text': 'Typ události "{event}" není registrován v EVENT_NOTIFICATION_CONFIG',
+
+ // Journey, Dashboard, Nav, DayPlan
+ 'common.justNow': 'právě teď',
+ 'common.hoursAgo': 'před {count} h',
+ 'common.daysAgo': 'před {count} d',
+ 'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
+ 'memories.testRouteNotConfigured': 'Testovací trasa není nakonfigurována pro tohoto poskytovatele',
+ 'memories.fillRequiredFields': 'Prosím vyplňte všechna povinná pole',
+ 'journey.title': 'Cestovní deník',
+ 'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
+ 'journey.new': 'Nový cestovní deník',
+ 'journey.create': 'Vytvořit',
+ 'journey.titlePlaceholder': 'Kam jedete?',
+ 'journey.empty': 'Zatím žádné cestovní deníky',
+ 'journey.emptyHint': 'Začněte dokumentovat svůj další výlet',
+ 'journey.deleted': 'Cestovní deník smazán',
+ 'journey.createError': 'Nepodařilo se vytvořit cestovní deník',
+ 'journey.deleteError': 'Nepodařilo se smazat cestovní deník',
+ 'journey.deleteConfirmTitle': 'Smazat',
+ 'journey.deleteConfirmMessage': 'Smazat „{title}"? Tuto akci nelze vrátit zpět.',
+ 'journey.deleteConfirmGeneric': 'Opravdu to chcete smazat?',
+ 'journey.notFound': 'Cestovní deník nenalezen',
+ 'journey.photos': 'Fotky',
+ 'journey.timelineEmpty': 'Zatím žádné zastávky',
+ 'journey.timelineEmptyHint': 'Přidejte odbavení nebo napište záznam do deníku',
+ 'journey.status.draft': 'Koncept',
+ 'journey.status.active': 'Aktivní',
+ 'journey.status.completed': 'Dokončeno',
+ 'journey.status.upcoming': 'Nadcházející',
+ 'journey.checkin.add': 'Odbavit se',
+ 'journey.checkin.namePlaceholder': 'Název místa',
+ 'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
+ 'journey.checkin.save': 'Uložit',
+ 'journey.checkin.error': 'Nepodařilo se uložit odbavení',
+ 'journey.entry.add': 'Deník',
+ 'journey.entry.edit': 'Upravit záznam',
+ 'journey.entry.titlePlaceholder': 'Název (volitelný)',
+ 'journey.entry.bodyPlaceholder': 'Co se dnes stalo?',
+ 'journey.entry.save': 'Uložit',
+ 'journey.entry.error': 'Nepodařilo se uložit záznam',
+ 'journey.photo.add': 'Fotka',
+ 'journey.photo.uploadError': 'Nahrávání selhalo',
+ 'journey.share.share': 'Sdílet',
+ 'journey.share.public': 'Veřejný',
+ 'journey.share.linkCopied': 'Veřejný odkaz zkopírován',
+ 'journey.share.disabled': 'Veřejné sdílení vypnuto',
+ 'journey.editor.titlePlaceholder': 'Pojmenujte tento okamžik...',
+ 'journey.editor.bodyPlaceholder': 'Vyprávějte příběh tohoto dne...',
+ 'journey.editor.placePlaceholder': 'Místo (volitelné)',
+ 'journey.editor.tagsPlaceholder': 'Tagy: skrytý klenot, nejlepší jídlo, musím se vrátit...',
+ 'journey.visibility.private': 'Soukromý',
+ 'journey.visibility.shared': 'Sdílený',
+ 'journey.visibility.public': 'Veřejný',
+ 'journey.emptyState.title': 'Váš příběh začíná zde',
+ 'journey.emptyState.subtitle': 'Odbavte se na místě nebo napište svůj první záznam do deníku',
+ 'journey.frontpage.subtitle': 'Proměňte své cesty v příběhy, na které nikdy nezapomenete',
+ 'journey.frontpage.createJourney': 'Vytvořit cestovní deník',
+ 'journey.frontpage.activeJourney': 'Aktivní cestovní deník',
+ 'journey.frontpage.allJourneys': 'Všechny cestovní deníky',
+ 'journey.frontpage.journeys': 'cestovní deníky',
+ 'journey.frontpage.createNew': 'Vytvořit nový cestovní deník',
+ 'journey.frontpage.createNewSub': 'Vyberte cesty, pište příběhy, sdílejte dobrodružství',
+ 'journey.frontpage.live': 'Živě',
+ 'journey.frontpage.synced': 'Synchronizováno',
+ 'journey.frontpage.continueWriting': 'Pokračovat v psaní',
+ 'journey.frontpage.updated': 'Aktualizováno {time}',
+ 'journey.frontpage.suggestionLabel': 'Cesta právě skončila',
+ 'journey.frontpage.suggestionText': 'Proměňte {title} v cestovní deník',
+ 'journey.frontpage.dismiss': 'Zavřít',
+ 'journey.frontpage.journeyName': 'Název cestovního deníku',
+ 'journey.frontpage.namePlaceholder': 'např. Jihovýchodní Asie 2026',
+ 'journey.frontpage.selectTrips': 'Vybrat cesty',
+ 'journey.frontpage.tripsSelected': 'cest vybráno',
+ 'journey.frontpage.trips': 'cesty',
+ 'journey.frontpage.placesImported': 'míst bude importováno',
+ 'journey.frontpage.places': 'místa',
+ 'journey.detail.backToJourney': 'Zpět na cestovní deník',
+ 'journey.detail.syncedWithTrips': 'Synchronizováno s cestami',
+ 'journey.detail.addEntry': 'Přidat záznam',
+ 'journey.detail.newEntry': 'Nový záznam',
+ 'journey.detail.editEntry': 'Upravit záznam',
+ 'journey.detail.noEntries': 'Zatím žádné záznamy',
+ 'journey.detail.noEntriesHint': 'Přidejte cestu pro začátek s kostrovými záznamy',
+ 'journey.detail.noPhotos': 'Zatím žádné fotky',
+ 'journey.detail.noPhotosHint': 'Nahrajte fotky k záznamům nebo procházejte knihovnu Immich/Synology',
+ 'journey.detail.journeyStats': 'Statistiky cesty',
+ 'journey.detail.syncedTrips': 'Synchronizované cesty',
+ 'journey.detail.noTripsLinked': 'Zatím žádné propojené cesty',
+ 'journey.detail.contributors': 'Přispěvatelé',
+ 'journey.detail.readMore': 'Číst dále',
+ 'journey.detail.prosCons': 'Klady a zápory',
+ 'journey.detail.photos': 'fotky',
+ 'journey.detail.day': 'Den {number}',
+ 'journey.detail.places': 'míst',
+ 'journey.stats.days': 'Dny',
+ 'journey.stats.cities': 'Města',
+ 'journey.stats.entries': 'Záznamy',
+ 'journey.stats.photos': 'Fotky',
+ 'journey.stats.places': 'Místa',
+ 'journey.skeletons.show': 'Zobrazit návrhy',
+ 'journey.skeletons.hide': 'Skrýt návrhy',
+ 'journey.verdict.lovedIt': 'Skvělé',
+ 'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
+ 'journey.synced.places': 'místa',
+ 'journey.synced.synced': 'synchronizováno',
+ 'journey.editor.uploadPhotos': 'Nahrát fotky',
+ 'journey.editor.uploading': 'Nahrávání...',
+ 'journey.editor.fromGallery': 'Z galerie',
+ 'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
+ 'journey.editor.writeStory': 'Napište svůj příběh...',
+ 'journey.editor.prosCons': 'Klady a zápory',
+ 'journey.editor.pros': 'Klady',
+ 'journey.editor.cons': 'Zápory',
+ 'journey.editor.proPlaceholder': 'Něco skvělého...',
+ 'journey.editor.conPlaceholder': 'Ne tak skvělé...',
+ 'journey.editor.addAnother': 'Přidat další',
+ 'journey.editor.date': 'Datum',
+ 'journey.editor.location': 'Místo',
+ 'journey.editor.searchLocation': 'Hledat místo...',
+ 'journey.editor.mood': 'Nálada',
+ 'journey.editor.weather': 'Počasí',
+ 'journey.editor.photoFirst': '1.',
+ 'journey.editor.makeFirst': 'Nastavit jako 1.',
+ 'journey.editor.searching': 'Hledání...',
+ 'journey.mood.amazing': 'Úžasný',
+ 'journey.mood.good': 'Dobrý',
+ 'journey.mood.neutral': 'Neutrální',
+ 'journey.mood.rough': 'Těžký',
+ 'journey.weather.sunny': 'Slunečno',
+ 'journey.weather.partly': 'Polojasno',
+ 'journey.weather.cloudy': 'Zataženo',
+ 'journey.weather.rainy': 'Deštivo',
+ 'journey.weather.stormy': 'Bouřlivo',
+ 'journey.weather.cold': 'Sněžení',
+ 'journey.trips.linkTrip': 'Propojit cestu',
+ 'journey.trips.searchTrip': 'Hledat cestu',
+ 'journey.trips.searchPlaceholder': 'Název cesty nebo cíl...',
+ 'journey.trips.noTripsAvailable': 'Žádné dostupné cesty',
+ 'journey.trips.link': 'Propojit',
+ 'journey.trips.tripLinked': 'Cesta propojena',
+ 'journey.trips.linkFailed': 'Propojení cesty selhalo',
+ 'journey.trips.addTrip': 'Přidat cestu',
+ 'journey.trips.unlinkTrip': 'Odpojit cestu',
+ 'journey.trips.unlinkMessage': 'Odpojit „{title}"? Všechny synchronizované záznamy a fotky z této cesty budou trvale smazány. Tuto akci nelze vrátit zpět.',
+ 'journey.trips.unlink': 'Odpojit',
+ 'journey.trips.tripUnlinked': 'Cesta odpojena',
+ 'journey.trips.unlinkFailed': 'Odpojení cesty selhalo',
+ 'journey.trips.noTripsLinkedSettings': 'Žádné propojené cesty',
+ 'journey.contributors.invite': 'Pozvat přispěvatele',
+ 'journey.contributors.searchUser': 'Hledat uživatele',
+ 'journey.contributors.searchPlaceholder': 'Uživatelské jméno nebo e-mail...',
+ 'journey.contributors.noUsers': 'Žádní uživatelé nenalezeni',
+ 'journey.contributors.role': 'Role',
+ 'journey.contributors.added': 'Přispěvatel přidán',
+ 'journey.contributors.addFailed': 'Přidání přispěvatele selhalo',
+ 'journey.share.publicShare': 'Veřejné sdílení',
+ 'journey.share.createLink': 'Vytvořit odkaz ke sdílení',
+ 'journey.share.linkCreated': 'Odkaz ke sdílení vytvořen',
+ 'journey.share.createFailed': 'Vytvoření odkazu selhalo',
+ 'journey.share.copy': 'Kopírovat',
+ 'journey.share.copied': 'Zkopírováno!',
+ 'journey.share.timeline': 'Časová osa',
+ 'journey.share.gallery': 'Galerie',
+ 'journey.share.map': 'Mapa',
+ 'journey.share.removeLink': 'Odstranit odkaz ke sdílení',
+ 'journey.share.linkDeleted': 'Odkaz ke sdílení smazán',
+ 'journey.share.deleteFailed': 'Smazání selhalo',
+ 'journey.share.updateFailed': 'Aktualizace selhala',
+
+ // Journey — Invite
+ 'journey.invite.role': 'Role',
+ 'journey.invite.viewer': 'Čtenář',
+ 'journey.invite.editor': 'Editor',
+ 'journey.invite.invite': 'Pozvat',
+ 'journey.invite.inviting': 'Zveme...',
+ 'journey.settings.title': 'Nastavení cestovního deníku',
+ 'journey.settings.coverImage': 'Titulní obrázek',
+ 'journey.settings.changeCover': 'Změnit obal',
+ 'journey.settings.addCover': 'Přidat titulní obrázek',
+ 'journey.settings.name': 'Název',
+ 'journey.settings.subtitle': 'Podtitul',
+ 'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
+ 'journey.settings.delete': 'Smazat',
+ 'journey.settings.deleteJourney': 'Smazat cestovní deník',
+ 'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
+ 'journey.settings.saved': 'Nastavení uloženo',
+ 'journey.settings.saveFailed': 'Uložení selhalo',
+ 'journey.settings.coverUpdated': 'Obal aktualizován',
+ 'journey.settings.coverFailed': 'Nahrávání selhalo',
+ 'journey.settings.failedToDelete': 'Smazání se nezdařilo',
+ 'journey.entries.deleteTitle': 'Smazat záznam',
+ 'journey.photosUploaded': '{count} fotografií nahráno',
+ 'journey.photosAdded': '{count} fotografií přidáno',
+ 'journey.public.notFound': 'Nenalezeno',
+ 'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
+ 'journey.public.readOnly': 'Pouze ke čtení · Veřejný cestovní deník',
+ 'journey.public.tagline': 'Travel Resource & Exploration Kit',
+ 'journey.public.sharedVia': 'Sdíleno přes',
+ 'journey.public.madeWith': 'Vytvořeno pomocí',
+ 'journey.pdf.journeyBook': 'Cestovní kniha',
+ 'journey.pdf.madeWith': 'Vytvořeno pomocí TREK',
+ 'journey.pdf.day': 'Den',
+ 'journey.pdf.theEnd': 'Konec',
+ 'journey.pdf.saveAsPdf': 'Uložit jako PDF',
+ 'journey.pdf.pages': 'stran',
+ 'journey.picker.tripPeriod': 'Období cesty',
+ 'journey.picker.dateRange': 'Časové období',
+ 'journey.picker.allPhotos': 'Všechny fotky',
+ 'journey.picker.albums': 'Alba',
+ 'journey.picker.selected': 'vybráno',
+ 'journey.picker.addTo': 'Přidat do',
+ 'journey.picker.newGallery': 'Nová galerie',
+ 'journey.picker.selectAll': 'Vybrat vše',
+ 'journey.picker.deselectAll': 'Zrušit výběr',
+ 'journey.picker.noAlbums': 'Žádná alba nenalezena',
+ 'journey.picker.selectDate': 'Vyberte datum',
+ 'journey.picker.search': 'Hledat',
+ 'dashboard.greeting.morning': 'Dobré ráno,',
+ 'dashboard.greeting.afternoon': 'Dobré odpoledne,',
+ 'dashboard.greeting.evening': 'Dobrý večer,',
+ 'dashboard.mobile.liveNow': 'Živě',
+ 'dashboard.mobile.tripProgress': 'Průběh cesty',
+ 'dashboard.mobile.daysLeft': 'Zbývá {count} dní',
+ 'dashboard.mobile.places': 'Místa',
+ 'dashboard.mobile.buddies': 'Spolucestující',
+ 'dashboard.mobile.newTrip': 'Nová cesta',
+ 'dashboard.mobile.currency': 'Měna',
+ 'dashboard.mobile.timezone': 'Časové pásmo',
+ 'dashboard.mobile.upcomingTrips': 'Nadcházející cesty',
+ 'dashboard.mobile.yourTrips': 'Vaše cesty',
+ 'dashboard.mobile.trips': 'cesty',
+ 'dashboard.mobile.starts': 'Začátek',
+ 'dashboard.mobile.duration': 'Doba trvání',
+ 'dashboard.mobile.day': 'den',
+ 'dashboard.mobile.days': 'dní',
+ 'dashboard.mobile.ongoing': 'Probíhající',
+ 'dashboard.mobile.startsToday': 'Začíná dnes',
+ 'dashboard.mobile.tomorrow': 'Zítra',
+ 'dashboard.mobile.inDays': 'Za {count} dní',
+ 'dashboard.mobile.inMonths': 'Za {count} měsíců',
+ 'dashboard.mobile.completed': 'Dokončeno',
+ 'dashboard.mobile.currencyConverter': 'Převodník měn',
+ 'nav.profile': 'Profil',
+ 'nav.bottomSettings': 'Nastavení',
+ 'nav.bottomAdmin': 'Nastavení správce',
+ 'nav.bottomLogout': 'Odhlásit se',
+ 'nav.bottomAdminBadge': 'Správce',
+ 'dayplan.mobile.addPlace': 'Přidat místo',
+ 'dayplan.mobile.searchPlaces': 'Hledat místa...',
+ 'dayplan.mobile.allAssigned': 'Všechna místa přiřazena',
+ 'dayplan.mobile.noMatch': 'Žádná shoda',
+ 'dayplan.mobile.createNew': 'Vytvořit nové místo',
+ 'admin.addons.catalog.journey.name': 'Cestovní deník',
+ 'admin.addons.catalog.journey.description': 'Sledování cest a cestovní deník s odbaveními, fotkami a denními příběhy',
+ // OAuth scope groups
+ 'oauth.scope.group.trips': 'Výlety',
+ 'oauth.scope.group.places': 'Místa',
+ 'oauth.scope.group.atlas': 'Atlas',
+ 'oauth.scope.group.packing': 'Balení',
+ 'oauth.scope.group.todos': 'Úkoly',
+ 'oauth.scope.group.budget': 'Rozpočet',
+ 'oauth.scope.group.reservations': 'Rezervace',
+ 'oauth.scope.group.collab': 'Spolupráce',
+ 'oauth.scope.group.notifications': 'Oznámení',
+ 'oauth.scope.group.vacay': 'Dovolená',
+ 'oauth.scope.group.geo': 'Geo',
+ 'oauth.scope.group.weather': 'Počasí',
+
+ // OAuth scope labels & descriptions
+ 'oauth.scope.trips:read.label': 'Zobrazit výlety a itineráře',
+ 'oauth.scope.trips:read.description': 'Číst výlety, dny, poznámky a členy',
+ 'oauth.scope.trips:write.label': 'Upravit výlety a itineráře',
+ 'oauth.scope.trips:write.description': 'Vytvářet a aktualizovat výlety, dny, poznámky a spravovat členy',
+ 'oauth.scope.trips:delete.label': 'Mazat výlety',
+ 'oauth.scope.trips:delete.description': 'Trvale smazat celé výlety — tato akce je nevratná',
+ 'oauth.scope.trips:share.label': 'Spravovat sdílené odkazy',
+ 'oauth.scope.trips:share.description': 'Vytvářet, aktualizovat a rušit veřejné sdílené odkazy',
+ 'oauth.scope.places:read.label': 'Zobrazit místa a mapová data',
+ 'oauth.scope.places:read.description': 'Číst místa, denní přiřazení, štítky a kategorie',
+ 'oauth.scope.places:write.label': 'Spravovat místa',
+ 'oauth.scope.places:write.description': 'Vytvářet, aktualizovat a mazat místa, přiřazení a štítky',
+ 'oauth.scope.atlas:read.label': 'Zobrazit Atlas',
+ 'oauth.scope.atlas:read.description': 'Číst navštívené země, regiony a seznam přání',
+ 'oauth.scope.atlas:write.label': 'Spravovat Atlas',
+ 'oauth.scope.atlas:write.description': 'Označovat navštívené země a regiony, spravovat seznam přání',
+ 'oauth.scope.packing:read.label': 'Zobrazit seznamy balení',
+ 'oauth.scope.packing:read.description': 'Číst položky, tašky a přiřazení kategorií',
+ 'oauth.scope.packing:write.label': 'Spravovat seznamy balení',
+ 'oauth.scope.packing:write.description': 'Přidávat, aktualizovat, mazat, označovat a řadit položky a tašky',
+ 'oauth.scope.todos:read.label': 'Zobrazit seznamy úkolů',
+ 'oauth.scope.todos:read.description': 'Číst úkoly výletu a přiřazení kategorií',
+ 'oauth.scope.todos:write.label': 'Spravovat seznamy úkolů',
+ 'oauth.scope.todos:write.description': 'Vytvářet, aktualizovat, označovat, mazat a řadit úkoly',
+ 'oauth.scope.budget:read.label': 'Zobrazit rozpočet',
+ 'oauth.scope.budget:read.description': 'Číst položky rozpočtu a přehled výdajů',
+ 'oauth.scope.budget:write.label': 'Spravovat rozpočet',
+ 'oauth.scope.budget:write.description': 'Vytvářet, aktualizovat a mazat položky rozpočtu',
+ 'oauth.scope.reservations:read.label': 'Zobrazit rezervace',
+ 'oauth.scope.reservations:read.description': 'Číst rezervace a podrobnosti ubytování',
+ 'oauth.scope.reservations:write.label': 'Spravovat rezervace',
+ 'oauth.scope.reservations:write.description': 'Vytvářet, aktualizovat, mazat a řadit rezervace',
+ 'oauth.scope.collab:read.label': 'Zobrazit spolupráci',
+ 'oauth.scope.collab:read.description': 'Číst poznámky, ankety a zprávy spolupráce',
+ 'oauth.scope.collab:write.label': 'Spravovat spolupráci',
+ 'oauth.scope.collab:write.description': 'Vytvářet, aktualizovat a mazat poznámky, ankety a zprávy',
+ 'oauth.scope.notifications:read.label': 'Zobrazit oznámení',
+ 'oauth.scope.notifications:read.description': 'Číst oznámení v aplikaci a počty nepřečtených',
+ 'oauth.scope.notifications:write.label': 'Spravovat oznámení',
+ 'oauth.scope.notifications:write.description': 'Označovat oznámení jako přečtená a reagovat na ně',
+ 'oauth.scope.vacay:read.label': 'Zobrazit plány dovolené',
+ 'oauth.scope.vacay:read.description': 'Číst data plánování dovolené, záznamy a statistiky',
+ 'oauth.scope.vacay:write.label': 'Spravovat plány dovolené',
+ 'oauth.scope.vacay:write.description': 'Vytvářet a spravovat záznamy dovolené, svátky a týmové plány',
+ 'oauth.scope.geo:read.label': 'Mapy a geokódování',
+ 'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
+ 'oauth.scope.weather:read.label': 'Předpovědi počasí',
+ 'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
}
export default cs
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index c9ebf453..2c9e54ee 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -1,6 +1,8 @@
const de: Record = {
// Allgemein
'common.save': 'Speichern',
+ 'common.showMore': 'Mehr anzeigen',
+ 'common.showLess': 'Weniger anzeigen',
'common.cancel': 'Abbrechen',
'common.delete': 'Löschen',
'common.edit': 'Bearbeiten',
@@ -8,6 +10,8 @@ const de: Record = {
'common.loading': 'Laden...',
'common.import': 'Importieren',
'common.error': 'Fehler',
+ 'common.unknownError': 'Unbekannter Fehler',
+ 'common.tooManyAttempts': 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
'common.back': 'Zurück',
'common.all': 'Alle',
'common.close': 'Schließen',
@@ -26,12 +30,21 @@ const de: Record = {
'common.email': 'E-Mail',
'common.password': 'Passwort',
'common.saving': 'Speichern...',
+ 'common.expand': 'Erweitern',
+ 'common.collapse': 'Einklappen',
+ 'common.justNow': 'gerade eben',
+ 'common.hoursAgo': 'vor {count}h',
+ 'common.daysAgo': 'vor {count}T',
'common.saved': 'Gespeichert',
'trips.reminder': 'Erinnerung',
'trips.reminderNone': 'Keine',
'trips.reminderDay': 'Tag',
'trips.reminderDays': 'Tage',
'trips.reminderCustom': 'Benutzerdefiniert',
+ 'trips.memberRemoved': '{username} entfernt',
+ 'trips.memberRemoveError': 'Entfernen fehlgeschlagen',
+ 'trips.memberAdded': '{username} hinzugefügt',
+ 'trips.memberAddError': 'Hinzufügen fehlgeschlagen',
'trips.reminderDaysBefore': 'Tage vor Abreise',
'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.',
'common.update': 'Aktualisieren',
@@ -134,6 +147,7 @@ const de: Record = {
'settings.tabs.notifications': 'Benachrichtigungen',
'settings.tabs.integrations': 'Integrationen',
'settings.tabs.account': 'Konto',
+ 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Über',
'settings.map': 'Karte',
'settings.mapTemplate': 'Karten-Vorlage',
@@ -179,9 +193,6 @@ const de: Record = {
'admin.notifications.none': 'Deaktiviert',
'admin.notifications.email': 'E-Mail (SMTP)',
'admin.notifications.webhook': 'Webhook',
- 'admin.notifications.events': 'Benachrichtigungsereignisse',
- 'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
- 'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.',
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
'admin.notifications.testWebhook': 'Test-Webhook senden',
@@ -228,6 +239,7 @@ const de: Record = {
'settings.mcp.endpoint': 'MCP-Endpunkt',
'settings.mcp.clientConfig': 'Client-Konfiguration',
'settings.mcp.clientConfigHint': 'Ersetze durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
+ 'settings.mcp.clientConfigHintOAuth': 'Ersetze und durch die Zugangsdaten des oben erstellten OAuth 2.1-Clients. mcp-remote öffnet beim ersten Verbindungsaufbau deinen Browser zur Autorisierung. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
'settings.mcp.copy': 'Kopieren',
'settings.mcp.copied': 'Kopiert!',
'settings.mcp.apiTokens': 'API-Tokens',
@@ -249,6 +261,48 @@ const de: Record = {
'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden',
'settings.mcp.toast.deleted': 'Token gelöscht',
'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden',
+ 'settings.mcp.apiTokensDeprecated': 'API-Tokens sind veraltet und werden in einer zukünftigen Version entfernt. Bitte verwende stattdessen OAuth 2.1-Clients.',
+ 'settings.oauth.clients': 'OAuth 2.1-Clients',
+ 'settings.oauth.clientsHint': 'Registriere OAuth 2.1-Clients, damit externe MCP-Anwendungen (Claude Web, Cursor usw.) sich ohne statische Tokens verbinden können.',
+ 'settings.oauth.createClient': 'Neuer Client',
+ 'settings.oauth.noClients': 'Keine OAuth-Clients registriert.',
+ 'settings.oauth.clientId': 'Client-ID',
+ 'settings.oauth.clientSecret': 'Client-Secret',
+ 'settings.oauth.deleteClient': 'Client löschen',
+ 'settings.oauth.deleteClientMessage': 'Dieser Client und alle aktiven Sessions werden dauerhaft entfernt. Jede Anwendung, die ihn nutzt, verliert sofort den Zugriff.',
+ 'settings.oauth.rotateSecret': 'Secret erneuern',
+ 'settings.oauth.rotateSecretMessage': 'Ein neues Client-Secret wird generiert und alle bestehenden Sessions werden sofort ungültig. Aktualisiere deine Anwendung, bevor du diesen Dialog schließt.',
+ 'settings.oauth.rotateSecretConfirm': 'Erneuern',
+ 'settings.oauth.rotateSecretConfirming': 'Wird erneuert…',
+ 'settings.oauth.rotateSecretDoneTitle': 'Neues Secret generiert',
+ 'settings.oauth.rotateSecretDoneWarning': 'Dieses Secret wird nur einmal angezeigt. Kopiere es jetzt und aktualisiere deine Anwendung — alle vorherigen Sessions wurden ungültig gemacht.',
+ 'settings.oauth.activeSessions': 'Aktive OAuth-Sessions',
+ 'settings.oauth.sessionScopes': 'Berechtigungen',
+ 'settings.oauth.sessionExpires': 'Läuft ab',
+ 'settings.oauth.revoke': 'Widerrufen',
+ 'settings.oauth.revokeSession': 'Session widerrufen',
+ 'settings.oauth.revokeSessionMessage': 'Dadurch wird der Zugriff für diese OAuth-Session sofort widerrufen.',
+ 'settings.oauth.modal.createTitle': 'OAuth-Client registrieren',
+ 'settings.oauth.modal.presets': 'Schnellvorlagen',
+ 'settings.oauth.modal.clientName': 'Anwendungsname',
+ 'settings.oauth.modal.clientNamePlaceholder': 'z. B. Claude Web, Meine MCP-App',
+ 'settings.oauth.modal.redirectUris': 'Redirect-URIs',
+ 'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
+ 'settings.oauth.modal.redirectUrisHint': 'Eine URI pro Zeile. HTTPS erforderlich (localhost ausgenommen). Exakte Übereinstimmung erforderlich.',
+ 'settings.oauth.modal.scopes': 'Erlaubte Berechtigungen',
+ 'settings.oauth.modal.scopesHint': 'list_trips und get_trip_summary sind immer verfügbar — keine Berechtigung nötig. Sie helfen der KI, Trip-IDs zu ermitteln.',
+ 'settings.oauth.modal.selectAll': 'Alle auswählen',
+ 'settings.oauth.modal.deselectAll': 'Alle abwählen',
+ 'settings.oauth.modal.creating': 'Wird registriert…',
+ 'settings.oauth.modal.create': 'Client registrieren',
+ 'settings.oauth.modal.createdTitle': 'Client registriert',
+ 'settings.oauth.modal.createdWarning': 'Das Client-Secret wird nur einmal angezeigt. Kopiere es jetzt — es kann nicht wiederhergestellt werden.',
+ 'settings.oauth.toast.createError': 'OAuth-Client konnte nicht registriert werden',
+ 'settings.oauth.toast.deleted': 'OAuth-Client gelöscht',
+ 'settings.oauth.toast.deleteError': 'OAuth-Client konnte nicht gelöscht werden',
+ 'settings.oauth.toast.revoked': 'Session widerrufen',
+ 'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
+ 'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
'settings.account': 'Konto',
'settings.about': 'Über',
'settings.about.reportBug': 'Bug melden',
@@ -371,6 +425,10 @@ const de: Record