Merge remote-tracking branch 'origin/dev' into feat/indonesian-translation

This commit is contained in:
jubnl
2026-04-15 06:28:35 +02:00
320 changed files with 44012 additions and 4351 deletions
+188
View File
@@ -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
+90 -28
View File
@@ -7,10 +7,24 @@ on:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
workflow_dispatch: 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: permissions:
contents: write contents: write
concurrency:
group: stable-build
cancel-in-progress: false
jobs: jobs:
version-bump: version-bump:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -20,48 +34,79 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version - name: Determine bump type and update version
id: bump id: bump
run: | run: |
# Check if this push is a merge commit from dev branch git fetch --tags
COMMIT_MSG=$(git log -1 --pretty=%s)
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
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" 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 elif [ "$BUMP_INPUT" = "patch" ]; then
BUMP="minor" NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
else
BUMP="patch" 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 fi
echo "Bump type: $BUMP" 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 "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 server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && 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 # Commit and tag
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" 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 commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION" git tag "v$NEW_VERSION"
git push origin main --follow-tags git push origin main --follow-tags
@@ -100,6 +145,8 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
- name: Export digest - name: Export digest
run: | run: |
@@ -140,14 +187,29 @@ jobs:
- name: Create and push multi-arch manifest - name: Create and push multi-arch manifest
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
VERSION=${{ needs.version-bump.outputs.version }} VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
docker buildx imagetools create \ docker buildx imagetools create \
-t mauriceboe/trek:latest \ -t "mauriceboe/trek:latest" \
-t mauriceboe/trek:$VERSION \ -t "mauriceboe/trek:$MAJOR_TAG" \
-t mauriceboe/nomad:latest \ -t "mauriceboe/trek:$VERSION" \
-t mauriceboe/nomad:$VERSION \
"${digests[@]}" "${digests[@]}"
- name: Inspect manifest - name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest 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
+13 -9
View File
@@ -1,7 +1,7 @@
name: Enforce PR Target Branch name: Enforce PR Target Branch
on: on:
pull_request: pull_request_target:
types: [opened, reopened, edited, synchronize] types: [opened, reopened, edited, synchronize]
jobs: jobs:
@@ -9,6 +9,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
pull-requests: write pull-requests: write
issues: write
contents: read
steps: steps:
- name: Flag or clear wrong base branch - name: Flag or clear wrong base branch
@@ -63,14 +65,16 @@ jobs:
repo: context.repo.repo, repo: context.repo.repo,
name: 'wrong-base-branch', name: 'wrong-base-branch',
}); });
} catch { } catch (err) {
await github.rest.issues.createLabel({ if (err.status === 404) {
owner: context.repo.owner, await github.rest.issues.createLabel({
repo: context.repo.repo, owner: context.repo.owner,
name: 'wrong-base-branch', repo: context.repo.repo,
color: 'd73a4a', name: 'wrong-base-branch',
description: 'PR is targeting the wrong base branch', color: 'd73a4a',
}); description: 'PR is targeting the wrong base branch',
});
}
} }
await github.rest.issues.addLabels({ await github.rest.issues.addLabels({
+2
View File
@@ -27,6 +27,8 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
EXPOSE 3000 EXPOSE 3000
+115 -20
View File
@@ -9,6 +9,10 @@ structured API.
## Table of Contents ## Table of Contents
- [Setup](#setup) - [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) - [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only) - [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write) - [Tools (read-write)](#tools-read-write)
@@ -22,22 +26,51 @@ structured API.
### 1. Enable the MCP addon (admin) ### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp` 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** MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop") management required — just provide the server URL:
3. **Copy the token immediately** — it is shown only once and cannot be recovered
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 **What happens automatically:**
`claude_desktop_config.json`: 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 ```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 <token>` 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. | | **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`. | | **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. | | **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. | | **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. | | **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
| **Token limits** | Maximum 10 API tokens per user. | | **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. | | **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. | | **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. | | **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. MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
| Prompt | Description | | Prompt | Description |
|-------------------|---------------------------------------------------------------------------------| |----------------------|---------------------------------------------------------------------------------|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. | | `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. | | `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. | | `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. |
--- ---
+40 -16
View File
@@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a> <a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a> <a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
@@ -77,7 +77,8 @@
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user - **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
### AI / MCP Integration ### 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 - **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 - **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 - **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
@@ -97,11 +98,23 @@
- **PWA**: vite-plugin-pwa + Workbox - **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`) - **Real-Time**: WebSocket (`ws`)
- **State**: Zustand - **State**: Zustand
- **Auth**: JWT + OIDC + TOTP (MFA) - **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional) - **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: Open-Meteo API (free, no key required) - **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react - **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 ## Quick Start
```bash ```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). - 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) - 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 - 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 - 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 # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production. # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For # - 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) # - 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 - 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_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID # - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret # - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button # - 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_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_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) # - 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) # - 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_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 # - 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_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5) # - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
@@ -180,7 +194,13 @@ services:
start_period: 15s start_period: 15s
``` ```
This example is aimed at reverse-proxy deployments. If you access TREK directly on `http://<host>: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://<host>: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 ```bash
docker compose up -d 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-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400; 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 / { 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 | | `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` | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `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 | | `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` | | `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` | 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` | | `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 for `X-Forwarded-For`. Use this only when TREK is actually behind a reverse proxy. | `1` | | `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` | | `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. | — | | `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** | | | | **OIDC / SSO** | | |
@@ -301,7 +325,7 @@ trek.yourdomain.com {
| `OIDC_CLIENT_ID` | OIDC client ID | — | | `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — | | `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` | | `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_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — | | `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` | | `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 | | `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| **Other** | | | | **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` | | `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
## Optional API Keys ## Optional API Keys
+15 -1
View File
@@ -10,8 +10,20 @@ This is a minimal Helm chart for deploying the TREK app.
- Optional generic Ingress support - Optional generic Ingress support
- Health checks on `/api/health` - 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 ## Usage
Or install directly from the local chart:
```sh ```sh
helm install trek ./chart \ helm install trek ./chart \
--set ingress.enabled=true \ --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. - `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. - 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.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. - 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.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 0.1.0 version: 2.9.13
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "latest" appVersion: "2.9.13"
@@ -27,7 +27,7 @@ spec:
fsGroup: 1000 fsGroup: 1000
containers: containers:
- name: trek - name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }} imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.resources }} {{- with .Values.resources }}
resources: resources:
+15 -9
View File
@@ -1,7 +1,7 @@
image: image:
repository: mauriceboe/trek repository: mauriceboe/trek
tag: latest # tag: latest
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
# Optional image pull secrets for private registries # Optional image pull secrets for private registries
@@ -19,17 +19,21 @@ env:
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin). # Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info" # LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details. # "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: "" # ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration. # NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com" # 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. # 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. # Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false" # 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" # 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" # 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" # ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address. # 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. # Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
@@ -40,7 +44,9 @@ env:
# OIDC_DISPLAY_NAME: "SSO" # OIDC_DISPLAY_NAME: "SSO"
# Label shown on the SSO login button. # Label shown on the SSO login button.
# OIDC_ONLY: "false" # 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_ADMIN_CLAIM: ""
# OIDC claim used to identify admin users. # OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: "" # OIDC_ADMIN_VALUE: ""
@@ -51,10 +57,10 @@ env:
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik). # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# DEMO_MODE: "false" # DEMO_MODE: "false"
# Enable demo mode (hourly data resets). # Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "60" # MCP_RATE_LIMIT: "300"
# Max MCP API requests per user per minute. Defaults to 60. # Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "5" # MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 5. # Max concurrent MCP sessions per user. Defaults to 20.
# Secret environment variables stored in a Kubernetes Secret. # Secret environment variables stored in a Kubernetes Secret.
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title> <title>TREK</title>
<!-- PWA / iOS --> <!-- PWA / iOS -->
+1710 -1821
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.12", "version": "2.9.13",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -17,8 +17,10 @@
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"marked": "^18.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
@@ -27,6 +29,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2", "react-router-dom": "^6.22.2",
"react-window": "^2.2.7", "react-window": "^2.2.7",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0", "topojson-client": "^3.1.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
@@ -40,8 +43,9 @@
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^4.1.2", "@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.18",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.1", "jsdom": "^29.0.1",
"msw": "^2.13.0", "msw": "^2.13.0",
"postcss": "^8.4.35", "postcss": "^8.4.35",
@@ -50,6 +54,6 @@
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^5.1.4", "vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0", "vite-plugin-pwa": "^0.21.0",
"vitest": "^4.1.2" "vitest": "^3.2.4"
} }
} }
+52 -5
View File
@@ -10,13 +10,20 @@ import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage' import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage' 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 SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast' import { ToastContainer } from './components/shared/Toast'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n' import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client' import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: ReactNode children: ReactNode
@@ -60,7 +67,12 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
return <Navigate to="/dashboard" replace /> return <Navigate to="/dashboard" replace />
} }
return <>{children}</> return (
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
)
} }
function RootRedirect() { function RootRedirect() {
@@ -78,16 +90,26 @@ function RootRedirect() {
} }
export default function App() { 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() const { loadSettings } = useSettingsStore()
useEffect(() => { useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) { if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
loadUser() // 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<string, PermissionLevel> }) => { 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<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true) if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(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?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone) if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
@@ -126,6 +148,11 @@ export default function App() {
} }
}, [isAuthenticated]) }, [isAuthenticated])
useEffect(() => {
registerSyncTriggers()
return () => unregisterSyncTriggers()
}, [])
const location = useLocation() const location = useLocation()
const isSharedPage = location.pathname.startsWith('/shared/') const isSharedPage = location.pathname.startsWith('/shared/')
@@ -158,11 +185,15 @@ export default function App() {
return ( return (
<TranslationProvider> <TranslationProvider>
<ToastContainer /> <ToastContainer />
<OfflineBanner />
<Routes> <Routes>
<Route path="/" element={<RootRedirect />} /> <Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} /> <Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} /> <Route path="/register" element={<LoginPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
@@ -219,6 +250,22 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/journey"
element={
<ProtectedRoute>
<JourneyPage />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute>
<JourneyDetailPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/notifications" path="/notifications"
element={ element={
+154 -4
View File
@@ -1,7 +1,36 @@
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import { getSocketId } from './websocket' import { getSocketId } from './websocket'
import en from '../i18n/translations/en'
import br from '../i18n/translations/br'
import de from '../i18n/translations/de'
import es from '../i18n/translations/es'
import fr from '../i18n/translations/fr'
import it from '../i18n/translations/it'
import nl from '../i18n/translations/nl'
import pl from '../i18n/translations/pl'
import cs from '../i18n/translations/cs'
import hu from '../i18n/translations/hu'
import ru from '../i18n/translations/ru'
import zh from '../i18n/translations/zh'
import zhTw from '../i18n/translations/zhTw'
import ar from '../i18n/translations/ar'
const apiClient: AxiosInstance = axios.create({ const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
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', baseURL: '/api',
withCredentials: true, withCredentials: true,
headers: { 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( apiClient.interceptors.request.use(
(config) => { (config) => {
const sid = getSocketId() const sid = getSocketId()
if (sid) { if (sid) {
config.headers['X-Socket-Id'] = 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 return config
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)
) )
// Response interceptor - handle 401 // Response interceptor - handle 401, 403 MFA, 429 rate limit
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { 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 const currentPath = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
} }
@@ -38,6 +79,16 @@ apiClient.interceptors.response.use(
) { ) {
window.location.href = '/settings?mfa=required' 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) 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 = { export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data), list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data), create: (data: Record<string, unknown>) => 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), 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), 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), 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 = { export const daysApi = {
@@ -105,8 +194,14 @@ export const placesApi = {
const fd = new FormData(); fd.append('file', file) 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) 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) => importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), 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 = { export const assignmentsApi = {
@@ -195,6 +290,8 @@ export const adminApi = {
apiClient.get('/admin/audit-log', { params }).then(r => r.data), apiClient.get('/admin/audit-log', { params }).then(r => r.data),
mcpTokens: () => apiClient.get('/admin/mcp-tokens').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), 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), getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').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), 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<string, unknown>) => 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<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => 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<string, unknown>) => 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 = { export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), 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), 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), 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), 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), 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 = { export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data), get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data), set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
+102
View File
@@ -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()
})
})
+56
View File
@@ -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<string, ScopeKeys> = {
'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<string, Array<{ scope: string } & ScopeInfo>> {
const groups: Record<string, Array<{ scope: string } & ScopeInfo>> = {}
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
}
+26 -5
View File
@@ -13,6 +13,8 @@ let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null let mySocketId: string | null = null
let connecting = false let connecting = false
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
let preReconnectHook: (() => Promise<void>) | null = null
export function getSocketId(): string | null { export function getSocketId(): string | null {
return mySocketId return mySocketId
@@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn 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<void>) | null): void {
preReconnectHook = fn
}
function getWsUrl(wsToken: string): string { function getWsUrl(wsToken: string): string {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws' const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${location.host}/ws?token=${wsToken}` return `${protocol}://${location.host}/ws?token=${wsToken}`
@@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise<void> {
} }
}) })
if (refetchCallback) { if (refetchCallback) {
activeTrips.forEach(tripId => { const doRefetch = () => {
try { refetchCallback!(tripId) } catch (err: unknown) { activeTrips.forEach(tripId => {
console.error('Failed to refetch trip data on reconnect:', err) 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()
}
} }
} }
} }
@@ -190,11 +190,12 @@ describe('AddonManager', () => {
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument(); 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( server.use(
http.get('/api/admin/addons', () => http.get('/api/admin/addons', () =>
HttpResponse.json({ HttpResponse.json({
addons: [ 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: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }), buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }), buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
@@ -204,18 +205,16 @@ describe('AddonManager', () => {
); );
render(<AddonManager />); render(<AddonManager />);
// Provider sub-rows are visible // Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash'); await screen.findByText('Unsplash');
expect(screen.getByText('Pexels')).toBeInTheDocument(); expect(screen.getByText('Pexels')).toBeInTheDocument();
// Memories row shows name override // Journey addon is rendered
expect(screen.getByText('Memories providers')).toBeInTheDocument(); expect(screen.getByText('Journey')).toBeInTheDocument();
// The photos addon row itself has no top-level toggle (hideToggle = true) // Toggle buttons: journey toggle + 2 provider toggles
// The toggle buttons are only for the providers
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); 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(3);
expect(toggleBtns.length).toBe(2);
}); });
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => { it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
+37 -42
View File
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast' 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 = { 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 { 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 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 integrationAddons = addons.filter(a => a.type === 'integration')
const photosAddon = tripAddons.find(isPhotosAddon)
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({ const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id, key: provider.id,
label: provider.name, label: provider.name,
@@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</div> </div>
{tripAddons.map(addon => ( {tripAddons.map(addon => (
<div key={addon.id}> <div key={addon.id}>
<AddonRow <AddonRow addon={addon} onToggle={handleToggle} t={t} />
addon={addon}
onToggle={handleToggle}
t={t}
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
hideToggle={photosAddon && addon.id === photosAddon.id}
/>
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( {addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}> <div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</span> </span>
</div> </div>
{globalAddons.map(addon => ( {globalAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} /> <div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
))} ))}
</div> </div>
)} )}
@@ -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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -197,4 +197,127 @@ describe('AdminMcpTokensPanel', () => {
render(<><ToastContainer /><AdminMcpTokensPanel /></>); render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens'); 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(<AdminMcpTokensPanel />);
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(<AdminMcpTokensPanel />);
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(<AdminMcpTokensPanel />);
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(<AdminMcpTokensPanel />);
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(<><ToastContainer /><AdminMcpTokensPanel /></>);
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(<><ToastContainer /><AdminMcpTokensPanel /></>);
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');
});
}); });
@@ -1,9 +1,21 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { adminApi } from '../../api/client' import { adminApi } from '../../api/client'
import { useToast } from '../shared/Toast' 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' 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 { interface AdminMcpToken {
id: number id: number
name: string name: string
@@ -14,21 +26,49 @@ interface AdminMcpToken {
username: string username: string
} }
const SCOPES_PREVIEW = 6
export default function AdminMcpTokensPanel() { export default function AdminMcpTokensPanel() {
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [tokens, setTokens] = useState<AdminMcpToken[]>([]) const [tokens, setTokens] = useState<AdminMcpToken[]>([])
const [isLoading, setIsLoading] = useState(true) const [tokensLoading, setTokensLoading] = useState(true)
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null) const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(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 toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
useEffect(() => { useEffect(() => {
setIsLoading(true) adminApi.oauthSessions()
.then(d => setSessions(d.sessions || []))
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
.finally(() => setSessionsLoading(false))
adminApi.mcpTokens() adminApi.mcpTokens()
.then(d => setTokens(d.tokens || [])) .then(d => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError'))) .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) => { const handleDelete = async (id: number) => {
try { try {
await adminApi.deleteMcpToken(id) await adminApi.deleteMcpToken(id)
@@ -47,55 +87,156 @@ export default function AdminMcpTokensPanel() {
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p> <p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
</div> </div>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}> {/* OAuth Sessions */}
{isLoading ? ( <div>
<div className="flex items-center justify-center py-12"> <h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} /> <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
</div> {sessionsLoading ? (
) : tokens.length === 0 ? ( <div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center justify-center py-12 gap-2"> <Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
</div> </div>
{tokens.map((token, i) => ( ) : sessions.length === 0 ? (
<div key={token.id} <div className="flex flex-col items-center justify-center py-12 gap-2">
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3" <Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}> <p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
<div className="min-w-0"> </div>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p> ) : (
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p> <>
</div> <div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}> style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" /> <span>{t('admin.oauthSessions.clientName')}</span>
<span className="whitespace-nowrap">{token.username}</span> <span>{t('admin.oauthSessions.owner')}</span>
</div> <span className="text-right">{t('admin.oauthSessions.created')}</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}> <span></span>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => 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')}>
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
))} {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 (
<div key={session.id}
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{visible.map(scope => (
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
{scope}
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => 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
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => 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
</button>
)}
</div>
</div>
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{session.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{new Date(session.created_at).toLocaleDateString(locale)}
</span>
<button onClick={() => 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')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
)
})}
</>
)}
</div>
</div> </div>
{/* MCP Tokens */}
<div>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{tokensLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : tokens.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
</div>
{tokens.map((token, i) => (
<div key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => 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')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</>
)}
</div>
</div>
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => 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')}
</button>
</div>
</div>
</div>
)}
{/* Delete MCP token modal */}
{deleteConfirmId !== null && ( {deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }} <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
@@ -133,7 +133,7 @@ describe('GitHubPanel', () => {
server.use( server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])), http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
); );
render(<GitHubPanel />); render(<GitHubPanel isPrerelease={true} />);
await screen.findByText('v3.0.0-beta.1'); await screen.findByText('v3.0.0-beta.1');
expect(screen.getByText('Pre-release')).toBeInTheDocument(); expect(screen.getByText('Pre-release')).toBeInTheDocument();
}); });
+11 -5
View File
@@ -6,12 +6,18 @@ import apiClient from '../../api/client'
const REPO = 'mauriceboe/TREK' const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10 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 { t, language } = useTranslation()
const [releases, setReleases] = useState([]) const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState({}) const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
@@ -273,7 +279,7 @@ export default function GitHubPanel() {
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} /> <div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
<div className="space-y-0"> <div className="space-y-0">
{releases.map((release, idx) => { {(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0 const isLatest = idx === 0
const isExpanded = expanded[release.id] const isExpanded = expanded[release.id]
@@ -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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import BudgetPanel from './BudgetPanel'; import BudgetPanel from './BudgetPanel';
@@ -418,4 +419,80 @@ describe('BudgetPanel', () => {
// Grand total card shows 300.00 // Grand total card shows 300.00
expect(screen.getByText('300.00')).toBeInTheDocument(); 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(<BudgetPanel tripId={1} />);
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(<BudgetPanel tripId={1} />);
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(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
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(<BudgetPanel tripId={1} />);
await screen.findByText('Snack');
// When expense_date is null, the fallback '—' is shown
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThan(0);
});
}); });
+11 -7
View File
@@ -956,15 +956,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} /> <PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
{pieSegments.map(seg => { {pieSegments.map((seg, i) => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return ( return (
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} /> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span> <div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span> <span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
</div>
</div> </div>
) )
})} })}
@@ -10,6 +10,7 @@ vi.mock('../../api/websocket', () => ({
disconnect: vi.fn(), disconnect: vi.fn(),
getSocketId: vi.fn(() => null), getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(), setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(), addListener: vi.fn(),
removeListener: vi.fn(), removeListener: vi.fn(),
})); }));
+8 -2
View File
@@ -370,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const [showEmoji, setShowEmoji] = useState(false) const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y } const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set()) const [deletingIds, setDeletingIds] = useState(new Set())
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => { deleteTimersRef.current.forEach(clearTimeout) }
}, [])
const containerRef = useRef(null) const containerRef = useRef(null)
const messagesRef = useRef(messages) const messagesRef = useRef(messages)
@@ -483,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId)) setDeletingIds(prev => new Set(prev).add(msgId))
}) })
setTimeout(async () => { const t = setTimeout(async () => {
try { try {
await collabApi.deleteMessage(tripId, msgId) await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m)) setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
} catch {} } catch {}
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s }) setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400) }, 400)
deleteTimersRef.current.push(t)
}, [tripId]) }, [tripId])
const handleReact = useCallback(async (msgId, emoji) => { const handleReact = useCallback(async (msgId, emoji) => {
@@ -762,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
)} )}
{/* Composer */} {/* Composer */}
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}> <div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
{/* Reply preview */} {/* Reply preview */}
{replyTo && ( {replyTo && (
<div style={{ <div style={{
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
disconnect: vi.fn(), disconnect: vi.fn(),
getSocketId: vi.fn(() => null), getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(), setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(), addListener: vi.fn(),
removeListener: vi.fn(), removeListener: vi.fn(),
})); }));
+5 -6
View File
@@ -3,9 +3,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' 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 { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
import { collabApi } from '../../api/client' import { collabApi } from '../../api/client'
import { getAuthUrl } from '../../api/authUrl' import { getAuthUrl } from '../../api/authUrl'
import { openFile } from '../../utils/fileDownload'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket' import { addListener, removeListener } from '../../api/websocket'
@@ -110,10 +112,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const isPdf = file.mime_type === 'application/pdf' const isPdf = file.mime_type === 'application/pdf'
const isTxt = file.mime_type?.startsWith('text/') const isTxt = file.mime_type?.startsWith('text/')
const openInNewTab = async () => { const openInNewTab = () => openFile(rawUrl).catch(() => {})
const u = await getAuthUrl(rawUrl, 'download')
window.open(u, '_blank', 'noreferrer')
}
return ReactDOM.createPortal( return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
@@ -845,7 +844,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
maxHeight: '4.5em', overflow: 'hidden', maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT, wordBreak: 'break-word', fontFamily: FONT,
}}> }}>
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
</div> </div>
)} )}
</div> </div>
@@ -1352,7 +1351,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
</div> </div>
</div> </div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}> <div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown> <Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && ( {(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}> <div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
@@ -13,6 +13,7 @@ vi.mock('../../api/websocket', () => ({
disconnect: vi.fn(), disconnect: vi.fn(),
getSocketId: vi.fn(() => null), getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(), setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(), addListener: vi.fn(),
removeListener: vi.fn(), removeListener: vi.fn(),
})) }))
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
disconnect: vi.fn(), disconnect: vi.fn(),
getSocketId: vi.fn(() => null), getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(), setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(), addListener: vi.fn(),
removeListener: vi.fn(), removeListener: vi.fn(),
})); }));
@@ -16,12 +16,13 @@ function formatTime(timeStr, is12h) {
} }
function formatDayLabel(date, t, locale) { function formatDayLabel(date, t, locale) {
const d = new Date(date + 'T00:00:00')
const now = new Date() 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 (date === nowDate) return t('collab.whatsNext.today') || 'Today'
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow' 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' }) return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
} }
+6 -13
View File
@@ -10,6 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl' import { getAuthUrl } from '../../api/authUrl'
import { downloadFile, openFile } from '../../utils/fileDownload'
function isImage(mimeType) { function isImage(mimeType) {
if (!mimeType) return false if (!mimeType) return false
@@ -30,16 +31,8 @@ function formatSize(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB` return `${(bytes / 1024 / 1024).toFixed(1)} MB`
} }
async function triggerDownload(url: string, filename: string) { function triggerDownload(url: string, filename: string) {
const authUrl = await getAuthUrl(url, 'download') downloadFile(url, filename).catch(() => {})
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 formatDateWithLocale(dateStr, locale) { function formatDateWithLocale(dateStr, locale) {
@@ -120,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
</span> </span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button <button
onClick={async () => { 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 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}> title={t('files.openTab')}>
<ExternalLink size={16} /> <ExternalLink size={16} />
@@ -750,7 +743,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button <button
onClick={async () => { 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' }} 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)'} onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}> 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} title={previewFile.original_name}
> >
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}> <p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={async () => { 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</button> <button onClick={() => openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
</p> </p>
</object> </object>
</div> </div>
@@ -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(<JournalBody text="Hello traveller" />);
expect(screen.getByText('Hello traveller')).toBeInTheDocument();
});
it('FE-COMP-JOURNALBODY-002: renders bold markdown as <strong>', () => {
const { container } = render(<JournalBody text="This is **bold** text" />);
const strong = container.querySelector('strong');
expect(strong).toBeInTheDocument();
expect(strong!.textContent).toBe('bold');
});
it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => {
render(<JournalBody text="[Visit](https://example.com)" />);
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(<JournalBody text="## Section Title" />);
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(<JournalBody text="" />);
expect(container.querySelector('.journal-body')).toBeInTheDocument();
});
});
@@ -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 (
<div className="journal-body" style={{
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 1.6,
color: 'inherit',
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
blockquote: ({ children }) => (
<blockquote style={{
borderLeft: `3px solid var(--journal-accent)`,
paddingLeft: 16, margin: '12px 0',
fontStyle: 'italic', color: 'var(--journal-muted)',
}}>{children}</blockquote>
),
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}>
{children}
</a>
),
ul: ({ children }) => <ul style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ol>,
li: ({ children }) => <li style={{ margin: '4px 0' }}>{children}</li>,
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
code: ({ children, className }) => {
const isBlock = className?.includes('language-')
if (isBlock) {
return (
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
}}>
<code>{children}</code>
</pre>
)
}
return (
<code style={{
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
borderRadius: 4, padding: '2px 5px', fontSize: '0.9em', fontFamily: 'monospace',
}}>{children}</code>
)
},
}}
>
{text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
</ReactMarkdown>
</div>
)
}
@@ -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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithoutCoords} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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<JourneyMapHandle>();
render(
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />
);
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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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('<svg');
expect(firstCall.html).toContain('</svg>');
// 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(
<JourneyMap checkins={[]} entries={entriesWithMood} />
);
// 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(
<JourneyMap checkins={[]} entries={threeEntries} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
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(
<JourneyMap checkins={[]} entries={singleEntry} />
);
// 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(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// 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('');
});
});
@@ -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 `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>
</div>`
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMap = forwardRef<JourneyMapHandle, Props>(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<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<Map<string, L.Marker>>(new Map())
const itemsRef = useRef<MapMarkerItem[]>([])
const highlightedRef = useRef<string | null>(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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
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 (
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
<div
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button
onClick={zoomIn}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}}
>+</button>
<button
onClick={zoomOut}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}}
></button>
</div>
</div>
)
})
export default JourneyMap
@@ -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<HTMLTextAreaElement>;
}
describe('MarkdownToolbar', () => {
let onUpdate: ReturnType<typeof vi.fn>;
beforeEach(() => {
onUpdate = vi.fn();
});
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
const ref = createTextareaRef();
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(8);
});
it('FE-COMP-MDTOOLBAR-002: buttons have correct title labels', () => {
const ref = createTextareaRef();
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
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(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
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(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
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(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
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(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
fireEvent.click(screen.getByTitle('Heading'));
expect(onUpdate).toHaveBeenCalledWith('## my title');
});
});
@@ -0,0 +1,84 @@
import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react'
interface Props {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
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 (
<div style={{
display: 'flex', gap: 2, padding: '6px 4px',
borderBottom: `1px solid var(--journal-border)`,
overflowX: 'auto',
}}>
{ACTIONS.map(a => (
<button
key={a.label}
type="button"
title={a.label}
onClick={() => 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'}
>
<a.icon size={15} />
</button>
))}
</div>
)
}
@@ -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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-002: shows photo image', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
// 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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
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(<PhotoLightbox photos={[]} onClose={onClose} />);
// 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(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
// 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();
});
});
@@ -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 (
<div
style={{
position: 'fixed', inset: 0, zIndex: 500,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Photo area — centered with nav overlays */}
<div
className="group/lightbox"
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
>
{/* Top bar */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
{idx + 1} / {photos.length}
</span>
<button onClick={onClose} style={{
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<X size={18} />
</button>
</div>
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && (
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronLeft size={22} />
</button>
)}
{/* Photo */}
<img
key={photo.id}
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
{/* Next button */}
{hasNext && (
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronRight size={22} />
</button>
)}
{/* Caption — bottom center overlay */}
{photo.caption && (
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{
fontSize: 14, fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10,
}}>{photo.caption}</p>
</div>
)}
</div>
</div>
)
}
@@ -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');
});
});
@@ -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<string, { bg: string; fg: string; darkBg: string; darkFg: string }> = {
'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' }
}
@@ -0,0 +1,38 @@
// FE-UTIL-STRIPMD-001 to FE-UTIL-STRIPMD-006
import { describe, it, expect } from 'vitest';
import { stripMarkdown } from './stripMarkdown';
describe('stripMarkdown', () => {
it('FE-UTIL-STRIPMD-001: strips bold and italic formatting', () => {
expect(stripMarkdown('**bold** and _italic_')).toBe('bold and italic');
expect(stripMarkdown('__also bold__ and *also italic*')).toBe('also bold and also italic');
});
it('FE-UTIL-STRIPMD-002: strips headings', () => {
expect(stripMarkdown('# Heading 1')).toBe('Heading 1');
expect(stripMarkdown('## Heading 2')).toBe('Heading 2');
expect(stripMarkdown('### Heading 3')).toBe('Heading 3');
});
it('FE-UTIL-STRIPMD-003: converts links to text and removes images', () => {
expect(stripMarkdown('[click here](https://example.com)')).toBe('click here');
expect(stripMarkdown('![alt text](image.jpg)')).toBe('');
});
it('FE-UTIL-STRIPMD-004: strips code blocks and inline code', () => {
expect(stripMarkdown('use `console.log`')).toBe('use console.log');
expect(stripMarkdown('```\ncode block\n```')).toBe('');
});
it('FE-UTIL-STRIPMD-005: strips blockquotes and lists', () => {
expect(stripMarkdown('> quoted text')).toBe('quoted text');
expect(stripMarkdown('- item one')).toBe('item one');
expect(stripMarkdown('1. first item')).toBe('first item');
});
it('FE-UTIL-STRIPMD-006: strips strikethrough and horizontal rules', () => {
expect(stripMarkdown('~~deleted~~')).toBe('deleted');
expect(stripMarkdown('---')).toBe('');
});
});
@@ -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()
}
@@ -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<typeof import('react-router-dom')>('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(<BottomNav />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />);
expect(screen.getByText('Trips')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
render(<BottomNav />);
expect(screen.getByText('Profile')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
const user = userEvent.setup();
render(<BottomNav />);
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(<BottomNav />);
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(<BottomNav />);
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(<BottomNav />);
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(<BottomNav />);
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(<BottomNav />);
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();
});
});
+164
View File
@@ -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<string, { to: string; label: string; icon: LucideIcon }> = {
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 (
<>
<nav
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
style={{
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
background: dark ? 'rgba(9,9,11,0.96)' : 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
{items.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`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'
}`
}
>
<Icon size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{label}</span>
</NavLink>
))}
<button
onClick={() => setShowProfile(true)}
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
>
<User size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
</button>
</nav>
{showProfile && <ProfileSheet onClose={() => 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 (
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Sheet */}
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={e => e.stopPropagation()}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-2">
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
{/* User info */}
<div className="px-6 pb-4 pt-1">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
{(user?.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
</div>
{user?.role === 'admin' && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
<Shield size={10} /> Admin
</span>
)}
</div>
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Links */}
<div className="py-2 px-2">
<button
onClick={() => 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"
>
<Settings size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
</button>
{user?.role === 'admin' && (
<button
onClick={() => 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"
>
<Shield size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
</button>
)}
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Logout */}
<div className="py-2 px-2">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
>
<LogOut size={18} className="text-red-500" />
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
</button>
</div>
<div className="h-4" />
</div>
</div>
)
}
@@ -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(<MobileTopHeader title="Journeys" />);
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(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
expect(screen.getByText('3 trips')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
const { container } = render(<MobileTopHeader title="Journeys" />);
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
expect(subtitleEl).not.toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
render(
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
);
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});
});
@@ -0,0 +1,17 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
}
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
return (
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
<div className="flex-1 min-w-0">
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
</div>
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
</div>
)
}
+1 -1
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })), http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })), 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() }); seedStore(useSettingsStore, { settings: buildSettings() });
}); });
+15 -11
View File
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n' 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 type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx' import InAppNotificationBell from './InAppNotificationBell.tsx'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe } const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
interface NavbarProps { interface NavbarProps {
tripTitle?: string tripTitle?: string
@@ -27,14 +27,13 @@ interface Addon {
} }
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { 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 { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore() const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false) const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const darkMode = settings.dark_mode const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) 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() if (user) loadAddons()
}, [user, location.pathname]) }, [user, location.pathname])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
})
}, [])
const handleLogout = () => { const handleLogout = () => {
logout() logout()
navigate('/login', { state: { noRedirect: true } }) navigate('/login', { state: { noRedirect: true } })
@@ -75,7 +68,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
touchAction: 'manipulation', touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)', paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)', 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 */} {/* Left side */}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{showBack && ( {showBack && (
@@ -155,6 +148,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button> </button>
)} )}
{/* Prerelease badge */}
{isPrerelease && appVersion && (
<span
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
{appVersion}
</span>
)}
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */} {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')} <button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex" className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
@@ -0,0 +1,86 @@
/**
* OfflineBanner persistent top bar indicating connectivity + sync state.
*
* States:
* offline + N queued amber bar "Offline — N changes queued"
* offline + 0 queued amber bar "Offline"
* online + N pending blue bar "Syncing N changes…"
* online + 0 pending hidden
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue'
const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [pendingCount, setPendingCount] = useState(0)
useEffect(() => {
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 (
<div
role="status"
aria-live="polite"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
background: bg,
color: text,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
paddingBottom: '6px',
paddingLeft: '16px',
paddingRight: '16px',
fontSize: 13,
fontWeight: 500,
}}
>
{offline
? <WifiOff size={14} />
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
}
{label}
</div>
)
}
@@ -233,8 +233,8 @@ describe('MemoriesPanel', () => {
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' }, { photo_id: 1, 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: 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', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, { photo_id: 10, 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: 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', () => http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ HttpResponse.json({
photos: [ photos: [
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, { photo_id: 10, 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: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
], ],
}) })
), ),
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
// ── Types ─────────────────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto { interface TripPhoto {
photo_id: number
asset_id: string asset_id: string
provider: string provider: string
user_id: number user_id: number
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
} }
function buildProviderAssetUrl(photo: TripPhoto, what: string): string { 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 { function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
const photo: TripPhoto = { // Picker photos are not yet saved — use provider-specific URL
asset_id: asset.id, return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
provider: asset.provider,
user_id: userId,
username: '',
shared: 0,
added_at: null
}
return buildProviderAssetUrl(photo, what)
} }
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
} }
// Lightbox // Lightbox
const [lightboxId, setLightboxId] = useState<string | null>(null) const [lightboxId, setLightboxId] = useState<number | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null) const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null) const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try { try {
await apiClient.delete(buildUnifiedUrl('photos'), { await apiClient.delete(buildUnifiedUrl('photos'), {
data: { data: {
asset_id: photo.asset_id, photo_id: photo.photo_id,
provider: photo.provider,
}, },
}) })
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')) } } catch { toast.error(t('memories.error.removePhoto')) }
} }
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try { try {
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), { await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared, shared,
asset_id: photo.asset_id, photo_id: photo.photo_id,
provider: photo.provider,
}) })
setTripPhotos(prev => prev.map(p => 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')) } } catch { toast.error(t('memories.error.toggleSharing')) }
} }
@@ -714,6 +706,23 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* Disconnected banner — shown when photos exist but provider is unreachable */}
{!connected && allVisible.length > 0 && enabledProviders.length > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', flexShrink: 0,
background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.25)',
fontSize: 12, color: 'var(--text-muted)',
}}>
<Camera size={13} style={{ color: '#ca8a04', flexShrink: 0 }} />
<span>
{t('memories.providerDisconnectedBanner', {
provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ')
})}
</span>
</div>
)}
{/* Header */} {/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}> <div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -822,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => { {allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id const isOwn = photo.user_id === currentUser?.id
return ( return (
<div key={`${photo.provider}:${photo.asset_id}`} className="group" <div key={photo.photo_id} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }} style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => { onClick={() => {
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) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('') setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -944,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowMobileInfo(false) 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 hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1 const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => { const navigateTo = (idx: number) => {
@@ -952,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!photo) return if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('') setLightboxOriginalSrc('')
setLightboxId(photo.asset_id) setLightboxId(photo.photo_id)
setLightboxUserId(photo.user_id) setLightboxUserId(photo.user_id)
setLightboxInfo(null) setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
@@ -99,4 +99,109 @@ describe('InAppNotificationItem', () => {
// Recent notification shows "just now" // Recent notification shows "just now"
expect(screen.getByText('just now')).toBeInTheDocument(); expect(screen.getByText('just now')).toBeInTheDocument();
}); });
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
render(
<InAppNotificationItem
notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })}
/>
);
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(
<InAppNotificationItem
notification={buildNotification({
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
})}
/>
);
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(
<InAppNotificationItem
notification={buildNotification({
id: 55,
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
response: null,
})}
/>
);
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(
<InAppNotificationItem
notification={buildNotification({
id: 66,
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
response: null,
})}
/>
);
await user.click(screen.getByText('No'));
expect(respondToBoolean).toHaveBeenCalledWith(66, 'negative');
});
it('FE-COMP-NOTIF-015: navigate notification shows action button', () => {
render(
<InAppNotificationItem
notification={buildNotification({
type: 'navigate',
navigate_text_key: 'notifications.title',
navigate_target: '/trips/1',
})}
/>
);
// 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(
<InAppNotificationItem
notification={buildNotification({
id: 77,
type: 'navigate',
navigate_text_key: 'notifications.title',
navigate_target: '/trips/1',
is_read: 0,
})}
onClose={onClose}
/>
);
// 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();
});
}); });
@@ -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(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// 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(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
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(<ScopeGroupPicker selected={[]} onChange={onChange} />);
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(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
// Now rerender with all scopes selected
rerender(<ScopeGroupPicker selected={allScopes} onChange={vi.fn()} />);
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(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
const onChange = vi.fn();
rerender(<ScopeGroupPicker selected={allScopes} onChange={onChange} />);
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(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// 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(<ScopeGroupPicker selected={[]} onChange={onChange} />);
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(<ScopeGroupPicker selected={[]} onChange={onChange} />);
// 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(<ScopeGroupPicker selected={[firstGroupScope]} onChange={vi.fn()} />);
// Count badge like "(1/N)" should be visible
expect(screen.getByText(/\(\d+\/\d+\)/)).toBeInTheDocument();
});
});
@@ -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<Record<string, boolean>>({})
const scopesByGroup = getScopesByGroup(t)
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
const allSelected = allScopeKeys.every(s => selected.includes(s))
return (
<div className="space-y-1">
<div className="flex justify-end mb-2">
<button
type="button"
onClick={() => 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')}
</button>
</div>
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
{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 (
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
<button
type="button"
onClick={() => 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]
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 flex-shrink-0" />}
{group}
{someGroupSelected && (
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
</span>
)}
</button>
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { 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}`}
/>
</div>
{open[group] && (
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
{groupScopes.map(({ scope, label, description }) => (
<label
key={scope}
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<input
type="checkbox"
checked={selected.includes(scope)}
onChange={e => onChange(
e.target.checked
? [...selected, scope]
: selected.filter(s => s !== scope)
)}
className="mt-0.5 rounded flex-shrink-0"
/>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
</div>
</label>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -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) => `<p>${str}</p>`,
},
}));
import { downloadJourneyBookPDF } from './JourneyBookPDF';
import type { JourneyDetail } from '../../store/journeyStore';
// ── Helpers ──────────────────────────────────────────────────────────────────
function buildJourney(overrides: Partial<JourneyDetail> = {}): 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<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
focus: ReturnType<typeof vi.fn>;
};
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('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
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');
});
});
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
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<string, JourneyEntry[]> {
const groups = new Map<string, JourneyEntry[]>()
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 `<div class="verdict-wrap"><div class="verdict-row">
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">Loved it</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">Could be better</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
</div></div>`
}
function renderPhotoBlock(photos: JourneyPhoto[]): string {
if (photos.length === 0) return ''
if (photos.length === 1) {
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`
}
if (photos.length === 2) {
return `<div class="entry-photo-duo">${photos.map(p => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`
}
// 3+ photos: hero left + stack right
return `<div class="entry-photo-trio">
<div class="photo-hero"><img src="${pSrc(photos[0])}" /></div>
<div class="photo-stack">
<div class="photo-cell"><img src="${pSrc(photos[1])}" /></div>
<div class="photo-cell"><img src="${pSrc(photos[2])}" /></div>
</div>
</div>`
}
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
? `<div class="day-header">Day ${di + 1} · ${fmtDate(date)}</div>`
: ''
// Photo block
const photoHtml = renderPhotoBlock(photos)
// Pros/cons
const prosconsHtml = renderProscons(entry)
// Story (markdown)
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
entryPages.push(`
<div class="entry-page">
${dayHeaderHtml}
${photoHtml}
<div class="entry-content">
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
${storyHtml}
${prosconsHtml}
</div>
</div>
`)
})
})
const totalPages = pageNum + 1 // +1 for closing page
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${esc(journey.title)} Journey Book</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, sans-serif; color: #1A1A1A; font-size: 11pt; line-height: 1.55; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@page { size: A4 landscape; margin: 0; }
/* ── Cover ─── */
.cover-page {
width: 100%; height: 100vh; position: relative; overflow: hidden;
background: #0a0a0f; color: white; display: flex; align-items: center; justify-content: center;
page-break-after: always;
}
.cover-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
.cover-dim { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
.cover-mesh { position: absolute; inset: 0; background: radial-gradient(circle at 20% 30%, rgba(99,102,241,0.2), transparent 50%), radial-gradient(circle at 80% 70%, rgba(236,72,153,0.15), transparent 50%); }
.cover-content { position: relative; z-index: 2; text-align: center; padding: 60pt; }
.cover-label { font-size: 9pt; font-weight: 700; letter-spacing: 6pt; text-transform: uppercase; opacity: 0.35; margin-bottom: 24pt; }
.cover-content h1 { font-size: 56pt; font-weight: 800; letter-spacing: -0.03em; line-height: 0.9; margin-bottom: 10pt; }
.cover-content .sub { font-size: 14pt; font-weight: 400; opacity: 0.7; margin-bottom: 36pt; }
.cover-stats { display: flex; gap: 48pt; justify-content: center; }
.cover-stat-val { font-size: 32pt; font-weight: 800; letter-spacing: -0.02em; }
.cover-stat-label { font-size: 10pt; text-transform: uppercase; letter-spacing: 2pt; opacity: 0.4; margin-top: 3pt; }
.cover-footer { position: absolute; bottom: 20pt; left: 0; right: 0; text-align: center; font-size: 9pt; opacity: 0.2; letter-spacing: 3pt; text-transform: uppercase; }
/* ── TOC ─── */
.toc-page {
width: 100%; height: 100vh; padding: 48pt 64pt; display: flex; flex-direction: column;
background: white; page-break-after: always;
}
.toc-top-label { font-size: 9pt; font-weight: 700; letter-spacing: 5pt; text-transform: uppercase; color: #94a3b8; margin-bottom: 16pt; }
.toc-title-block h2 { font-size: 36pt; font-weight: 800; letter-spacing: -1pt; color: #0a0a0f; margin-bottom: 4pt; }
.toc-title-block .sub { font-size: 13pt; color: #71717a; margin-bottom: 24pt; }
.toc-divider { height: 1pt; background: #e4e4e7; margin: 16pt 0; }
.toc-body { flex: 1; columns: 2; column-gap: 40pt; }
.toc-day { break-inside: avoid; margin-bottom: 14pt; }
.toc-day-label { font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: #71717a; margin-bottom: 4pt; }
.toc-entry { display: flex; align-items: baseline; gap: 4pt; font-size: 11pt; color: #3f3f46; margin-bottom: 2pt; }
.toc-entry .toc-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200pt; }
.toc-entry .toc-dots { flex: 1; border-bottom: 1pt dotted #d4d4d8; margin: 0 4pt; min-width: 20pt; }
.toc-entry .toc-page { font-size: 10pt; color: #a1a1aa; font-weight: 500; flex-shrink: 0; }
.toc-stats { display: flex; gap: 32pt; margin-top: auto; padding-top: 16pt; border-top: 1pt solid #e4e4e7; }
.toc-stat-val { font-size: 18pt; font-weight: 800; color: #0a0a0f; }
.toc-stat-label { font-size: 9pt; text-transform: uppercase; letter-spacing: 1pt; color: #94a3b8; }
/* ── Entry Page ─── */
.entry-page {
width: 100%; min-height: 100vh; padding: 56pt 48pt 48pt;
page-break-after: always;
display: flex; flex-direction: column;
}
/* Day header — inline */
.day-header {
font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
color: #71717a; text-align: center; margin-bottom: 16pt; position: relative;
display: flex; align-items: center; gap: 12pt;
}
.day-header::before, .day-header::after { content: ''; flex: 1; height: 0.5pt; background: #d4d4d8; }
/* Photos */
.entry-photo-single { border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 55vh; }
.entry-photo-single img { width: 100%; height: 100%; object-fit: cover; display: block; }
.entry-photo-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 45vh; }
.entry-photo-trio { display: grid; grid-template-columns: 3fr 2fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 50vh; }
.photo-cell { overflow: hidden; }
.photo-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-hero { overflow: hidden; }
.photo-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-stack { display: flex; flex-direction: column; gap: 6pt; }
.photo-stack .photo-cell { flex: 1; }
/* Entry content */
.entry-content { flex: 1; }
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
.entry-story p { margin: 0 0 8pt; }
.entry-story strong { font-weight: 600; color: #0a0a0f; }
.entry-story em { font-style: italic; }
.entry-story blockquote { margin: 12pt 0; padding-left: 12pt; border-left: 2pt solid #d4d4d8; font-style: italic; color: #52525b; }
.entry-story ul, .entry-story ol { margin: 8pt 0; padding-left: 16pt; }
.entry-story li { margin-bottom: 4pt; }
.entry-story a { color: #2563eb; text-decoration: none; }
/* Verdict */
.verdict-wrap { break-inside: avoid; padding-top: 14pt; }
.verdict-row { display: flex; gap: 10pt; }
.verdict-card { flex: 1; padding: 10pt 12pt; border-radius: 6pt; font-size: 9.5pt; }
.verdict-card.pros { background: #f0fdf4; border: 0.5pt solid #bbf7d0; }
.verdict-card.cons { background: #fef2f2; border: 0.5pt solid #fecaca; }
.verdict-label { font-size: 8pt; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6pt; }
.verdict-card.pros .verdict-label { color: #15803d; }
.verdict-card.cons .verdict-label { color: #b91c1c; }
.verdict-card ul { margin: 0; padding: 0; list-style: none; }
.verdict-card li { padding: 2pt 0; position: relative; padding-left: 10pt; }
.verdict-card li::before { content: '•'; position: absolute; left: 0; }
.verdict-card.pros li { color: #14532d; }
.verdict-card.pros li::before { color: #22c55e; }
.verdict-card.cons li { color: #7f1d1d; }
.verdict-card.cons li::before { color: #ef4444; }
/* ── Closing ─── */
.closing-page {
width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
background: #0a0a0f; color: white; text-align: center; page-break-after: auto;
}
.closing-title { font-size: 32pt; font-weight: 300; letter-spacing: -1pt; opacity: 0.6; margin-bottom: 8pt; }
.closing-sub { font-size: 10pt; opacity: 0.25; letter-spacing: 3pt; text-transform: uppercase; }
/* ── Print ─── */
@media print {
.print-bar { display: none !important; }
body { margin: 0; }
.entry-page { orphans: 3; widows: 3; }
h2.entry-title { page-break-after: avoid; }
.verdict-row { page-break-inside: avoid; }
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
}
.print-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
}
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
.print-bar .btn-save { background: white; color: #0f172a; }
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
</style>
</head>
<body>
<div class="print-bar">
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
<button class="btn-save" onclick="window.print()">Save as PDF</button>
<button class="btn-close" onclick="window.close()">Close</button>
</div>
<!-- Page 1: Cover -->
<div class="cover-page">
${coverUrl ? `<div class="cover-bg" style="background-image:url('${coverUrl}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-mesh"></div>
<div class="cover-content">
<div class="cover-label">Journey Book</div>
<h1>${esc(journey.title)}</h1>
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
<div class="cover-stats">
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">Days</div></div>
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">Entries</div></div>
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">Photos</div></div>
</div>
</div>
<div class="cover-footer">Made with TREK</div>
</div>
<!-- Entry Pages -->
${entryPages.join('\n')}
<!-- Closing Page -->
<div class="closing-page">
<div>
<div class="closing-title">The End</div>
<div class="closing-sub">Made with TREK · ${new Date().getFullYear()}</div>
</div>
</div>
</body>
</html>`
const win = window.open('', '_blank')
if (!win) return
win.document.write(html)
win.document.close()
}
+22 -15
View File
@@ -1,7 +1,7 @@
// Trip PDF via browser print window // Trip PDF via browser print window
import { createElement } from 'react' import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons' 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 { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' 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' }) return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
} }
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
function transportIconSvg(type) { const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
const Icon = TRANSPORT_ICON_MAP[type] || Ticket function reservationIconSvg(type) {
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }) 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 } 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 notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc) const cost = dayCost(assignments, day.id, loc)
// Transport bookings for this day // Reservations for this day (hotel rendered via accommodations block)
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const dayReservations = (reservations || []).filter(r => {
const dayTransport = (reservations || []).filter(r => { if (!r.reservation_time || r.type === 'hotel') return false
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
return day.date && r.reservation_time.split('T')[0] === day.date return day.date && r.reservation_time.split('T')[0] === day.date
}) })
const merged = [] const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) 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 })) 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) 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) 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 const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>` ? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => { : merged.map(item => {
if (item.type === 'transport') { if (item.type === 'reservation') {
const r = item.data const r = item.data
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) 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 = '' 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(' · ') 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 === '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) : '' const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
return ` return `
<div class="note-card" style="border-left: 3px solid #3b82f6;"> <div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: #3b82f6;"></div> <div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span> <span class="note-icon">${icon}</span>
<div class="note-body"> <div class="note-body">
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div> <div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''} ${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''} ${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
</div> </div>
</div>` </div>`
@@ -467,6 +467,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
const [showAddItem, setShowAddItem] = useState(false) const [showAddItem, setShowAddItem] = useState(false)
const [newItemName, setNewItemName] = useState('') const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null) const addItemRef = useRef<HTMLInputElement>(null)
const menuBtnRef = useRef<HTMLButtonElement>(null)
const assigneeDropdownRef = useRef<HTMLDivElement>(null) const assigneeDropdownRef = useRef<HTMLDivElement>(null)
const { togglePackingItem } = useTripStore() const { togglePackingItem } = useTripStore()
const toast = useToast() const toast = useToast()
@@ -629,22 +630,27 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
</span> </span>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }} <button ref={menuBtnRef} onClick={() => 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)'}> onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<MoreHorizontal size={15} /> <MoreHorizontal size={15} />
</button> </button>
{showMenu && ( {showMenu && (() => {
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }} const rect = menuBtnRef.current?.getBoundingClientRect();
onMouseLeave={() => setShowMenu(false)}> return (
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />} <>
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} /> <div style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowMenu(false)} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} /> <div style={{ position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 4 : 0, zIndex: 100, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}>
{canEdit && <> {canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} /> <MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} /> <MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
</>} {canEdit && <>
</div> <div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
)} <MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</>}
</div>
</>
);
})()}
</div> </div>
</div> </div>
@@ -149,7 +149,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
value={caption} value={caption}
onChange={e => setCaption(e.target.value)} onChange={e => setCaption(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()} 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" 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 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" className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
onClick={() => setEditCaption(true)} onClick={() => setEditCaption(true)}
> >
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>} {photo.caption || <span className="text-white/40 italic">{t('photos.addCaption')}</span>}
</p> </p>
<button <button
onClick={() => setEditCaption(true)} onClick={() => setEditCaption(true)}
@@ -43,15 +43,15 @@ describe('PhotoUpload', () => {
it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => { it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
expect(screen.getByText('Fotos hier ablegen')).toBeInTheDocument() expect(screen.getByText('Drop photos here')).toBeInTheDocument()
// Upload icon rendered via lucide-react as SVG // Upload icon rendered via lucide-react as SVG
expect(document.querySelector('svg')).toBeTruthy() expect(document.querySelector('svg')).toBeTruthy()
}) })
it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => { it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
expect(screen.queryByText('Tag verknüpfen')).not.toBeInTheDocument() expect(screen.queryByText('Link Day')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Optionale Beschriftung...')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('Optional caption...')).not.toBeInTheDocument()
}) })
it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => { it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => {
@@ -65,27 +65,27 @@ describe('PhotoUpload', () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()]) await uploadFiles([makeFile()])
expect(screen.getByAltText('photo.jpg')).toBeInTheDocument() expect(screen.getByAltText('photo.jpg')).toBeInTheDocument()
expect(screen.getByText('Tag verknüpfen')).toBeInTheDocument() expect(screen.getByText('Link Day')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Optionale Beschriftung...')).toBeInTheDocument() expect(screen.getByPlaceholderText('Optional caption...')).toBeInTheDocument()
}) })
it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => { it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')]) 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 () => { it('FE-COMP-PHOTOUPLOAD-006: remove button removes a file from preview', async () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')]) 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 // Remove buttons are inside `.relative.aspect-square` wrappers in the preview grid
const removeButtons = document.querySelectorAll('.relative.aspect-square button') const removeButtons = document.querySelectorAll('.relative.aspect-square button')
expect(removeButtons.length).toBe(2) expect(removeButtons.length).toBe(2)
await userEvent.click(removeButtons[0]) 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) expect(screen.getAllByRole('img').length).toBe(1)
}) })
@@ -120,7 +120,7 @@ describe('PhotoUpload', () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()]) await uploadFiles([makeFile()])
await userEvent.type(screen.getByPlaceholderText('Optionale Beschriftung...'), 'Vacation') await userEvent.type(screen.getByPlaceholderText('Optional caption...'), 'Vacation')
await userEvent.click(getSubmitButton()) await userEvent.click(getSubmitButton())
@@ -146,7 +146,7 @@ describe('PhotoUpload', () => {
await userEvent.click(getSubmitButton()) await userEvent.click(getSubmitButton())
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/wird hochgeladen/i)).toBeInTheDocument() expect(screen.getAllByText(/uploading/i).length).toBeGreaterThan(0)
}) })
expect(getSubmitButton()).toBeDisabled() expect(getSubmitButton()).toBeDisabled()
+10 -10
View File
@@ -85,12 +85,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
<input {...getInputProps()} /> <input {...getInputProps()} />
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} /> <Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
{isDragActive ? ( {isDragActive ? (
<p className="text-slate-700 font-medium">Fotos hier ablegen...</p> <p className="text-slate-700 font-medium">{t('photos.dropHere')}</p>
) : ( ) : (
<> <>
<p className="text-gray-600 font-medium">Fotos hier ablegen</p> <p className="text-gray-600 font-medium">{t('photos.dropHereActive')}</p>
<p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p> <p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p>
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p> <p className="text-gray-400 text-xs mt-2">{t('photos.fileTypeHint')}</p>
</> </>
)} )}
</div> </div>
@@ -98,7 +98,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
{/* Preview grid */} {/* Preview grid */}
{files.length > 0 && ( {files.length > 0 && (
<div> <div>
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} Foto{files.length !== 1 ? 's' : ''} ausgewählt</p> <p className="text-sm font-medium text-gray-700 mb-2">{files.length} {t(files.length !== 1 ? 'photos.photosSelected' : 'photos.photoSelected')}</p>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto"> <div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto">
{files.map((file, idx) => ( {files.map((file, idx) => (
<div key={idx} className="relative aspect-square group"> <div key={idx} className="relative aspect-square group">
@@ -126,15 +126,15 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
{files.length > 0 && ( {files.length > 0 && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">Tag verknüpfen</label> <label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkDay')}</label>
<select <select
value={dayId} value={dayId}
onChange={e => setDayId(e.target.value)} onChange={e => 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" 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"
> >
<option value="">Kein Tag</option> <option value="">{t('photos.noDay')}</option>
{(days || []).map(day => ( {(days || []).map(day => (
<option key={day.id} value={day.id}>Tag {day.day_number}</option> <option key={day.id} value={day.id}>{t('photos.dayLabel', { number: day.day_number })}</option>
))} ))}
</select> </select>
</div> </div>
@@ -152,12 +152,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
</select> </select>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-xs font-medium text-gray-700 mb-1">Beschriftung (für alle)</label> <label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.captionForAll')}</label>
<input <input
type="text" type="text"
value={caption} value={caption}
onChange={e => setCaption(e.target.value)} onChange={e => 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" 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"
/> />
</div> </div>
@@ -169,7 +169,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
<div className="bg-slate-50 rounded-lg p-3"> <div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-slate-900">Wird hochgeladen...</span> <span className="text-sm text-slate-900">{t('common.uploading')}</span>
</div> </div>
<div className="w-full bg-slate-200 rounded-full h-1.5"> <div className="w-full bg-slate-200 rounded-full h-1.5">
<div <div
@@ -167,7 +167,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
<div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}> <div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{ <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)', backdropFilter: 'blur(40px) saturate(180%)',
@@ -189,7 +189,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
</div> </div>
{!collapsed && formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>} {!collapsed && formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>}
</div> </div>
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? 'Expand' : 'Collapse'} <button onClick={(e) => { 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' }} 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)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}> onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
+125 -19
View File
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom' 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 } 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' import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -55,6 +55,99 @@ const TYPE_ICONS = {
car: '🚗', cruise: '🚢', event: '🎫', other: '📋', 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 (
<div className="md:hidden" style={{ padding: '8px 12px 12px' }}>
{!open ? (
<button
onClick={e => { 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',
}}
>
<Plus size={14} />
Add Place
</button>
) : (
<div style={{ borderRadius: 14, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', overflow: 'hidden' }}>
<div style={{ padding: '8px 10px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 6 }}>
<input
autoFocus
value={search}
onChange={e => 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)' }}
/>
<button onClick={() => { setOpen(false); setSearch('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
{filtered.length === 0 && (
<div style={{ padding: '16px 12px', textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>
{available.length === 0 ? t('dayplan.mobile.allAssigned') : t('dayplan.mobile.noMatch')}
</div>
)}
{filtered.slice(0, 20).map(p => (
<button
key={p.id}
onClick={() => {
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',
}}
>
<MapPin size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</button>
))}
</div>
{onAddNew && (
<button
onClick={() => { 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',
}}
>
<Plus size={13} />
Create new place
</button>
)}
</div>
)}
</div>
)
}
interface DayPlanSidebarProps { interface DayPlanSidebarProps {
tripId: number tripId: number
trip: Trip trip: Trip
@@ -79,6 +172,8 @@ interface DayPlanSidebarProps {
reservations?: Reservation[] reservations?: Reservation[]
onAddReservation: () => void onAddReservation: () => void
onNavigateToFiles?: () => void onNavigateToFiles?: () => void
onAddPlace?: () => void
onAddPlaceToDay?: (placeId: number, dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean canUndo?: boolean
@@ -95,6 +190,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [], reservations = [],
onAddReservation, onAddReservation,
onAddPlace,
onAddPlaceToDay,
onNavigateToFiles, onNavigateToFiles,
onExpandedDaysChange, onExpandedDaysChange,
pushUndo, pushUndo,
@@ -519,7 +616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) 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) => { const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
@@ -606,7 +703,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
tripActions.setAssignments(currentAssignments) tripActions.setAssignments(currentAssignments)
} }
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Unknown error') toast.error(err instanceof Error ? err.message : t('common.unknownError'))
return return
} }
@@ -755,9 +852,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex) 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) { } 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) setDraggingId(null)
setDropTargetKey(null) setDropTargetKey(null)
@@ -862,7 +959,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
a.download = `${trip?.title || 'trip'}.ics` a.download = `${trip?.title || 'trip'}.ics`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch { toast.error('ICS export failed') } } catch { toast.error(t('planner.icsExportFailed')) }
}} }}
onMouseEnter={() => setIcsHover(true)} onMouseEnter={() => setIcsHover(true)}
onMouseLeave={() => setIcsHover(false)} onMouseLeave={() => setIcsHover(false)}
@@ -1089,11 +1186,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
} else if (assignmentId && fromDayId !== 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) { } else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter) handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
} else if (noteId && fromDayId !== day.id) { } 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) { } else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter) 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 setDropTargetKey(null); window.__dragData = null; return
} }
if (assignmentId && fromDayId !== day.id) { 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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
if (noteId && fromDayId !== day.id) { 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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
const m = getMergedItems(day.id) const m = getMergedItems(day.id)
@@ -1207,7 +1304,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null setDropTargetKey(null); window.__dragData = null
} else if (fromAssignmentId && fromDayId !== day.id) { } else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id) 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 tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.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 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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) { } else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) 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 && 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) }, 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.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 }, { divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, 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) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromAssignmentId && fromDayId !== 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) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
} else if (noteId && fromDayId !== day.id) { } 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) { } else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter) 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 tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.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 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) setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) { } else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', 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 tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.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 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) setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) 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 && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) { 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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
if (noteId && fromDayId !== day.id) { 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 setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
const m = getMergedItems(day.id) const m = getMergedItems(day.id)
@@ -1623,6 +1720,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
</div> </div>
)} )}
{/* Mobile: Add Place from list */}
<MobileAddPlaceButton
dayId={day.id}
places={places}
assignments={assignments}
onAssign={onAssignToDay}
onAddNew={onAddPlace}
/>
</div> </div>
)} )}
</div> </div>
@@ -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) => 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<HTMLInputElement>(null)
const [file, setFile] = useState<File | null>(null)
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [summary, setSummary] = useState<PlacesImportSummary | null>(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<HTMLInputElement>) => {
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(
<div
onClick={handleClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
>
<div
onClick={e => 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" }}
>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
{t('places.importFile')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('places.importFileHint')}
</div>
<input
ref={fileInputRef}
type="file"
accept=".gpx,.kml,.kmz"
style={{ display: 'none' }}
onChange={handleInputChange}
/>
<div
onClick={() => 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,
}}
>
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
{isDragOver ? (
<span style={{ color: 'var(--accent)', pointerEvents: 'none' }}>{t('places.importFileDropActive')}</span>
) : file ? (
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{file.name}</span>
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('places.importFileDropHere')}</span>
)}
</div>
{summary && (
<div style={{
border: '1px solid var(--border-primary)', borderRadius: 10,
background: 'var(--bg-tertiary)', padding: 10, marginBottom: 10,
}}>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('places.kmlKmzSummaryValues', {
total: summary.totalPlacemarks,
created: summary.createdCount,
skipped: summary.skippedCount,
})}
</div>
{summary.warnings?.length > 0 && (
<div style={{ marginTop: 8, fontSize: 12, color: '#b45309', whiteSpace: 'pre-wrap' }}>
{summary.warnings.join('\n')}
</div>
)}
</div>
)}
{error && (
<div style={{
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10,
background: 'rgba(239,68,68,0.08)', padding: '8px 10px',
fontSize: 12, color: '#b91c1c', whiteSpace: 'pre-wrap', marginBottom: 10,
}}>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={handleClose}
style={{
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.cancel')}
</button>
<button
onClick={handleImport}
disabled={!canImport}
style={{
padding: '8px 16px', borderRadius: 10, border: 'none',
background: canImport ? 'var(--accent)' : 'var(--bg-tertiary)',
color: canImport ? 'var(--accent-text)' : 'var(--text-faint)',
fontSize: 13, fontWeight: 500, cursor: canImport ? 'pointer' : 'default',
fontFamily: 'inherit',
}}
>
{loading ? t('common.loading') : t('common.import')}
</button>
</div>
</div>
</div>,
document.body
)
}
+221 -29
View File
@@ -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 Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
@@ -6,7 +6,7 @@ import { useAuthStore } from '../../store/authStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast' 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 { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import type { Place, Category, Assignment } from '../../types' import type { Place, Category, Assignment } from '../../types'
@@ -25,6 +25,25 @@ interface PlaceFormData {
website: string 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.<tld> or maps.google.<sld>.<tld> — 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 = { const DEFAULT_FORM: PlaceFormData = {
name: '', name: '',
description: '', description: '',
@@ -65,6 +84,10 @@ export default function PlaceFormModal({
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [pendingFiles, setPendingFiles] = useState([]) const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null) const fileRef = useRef(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const acAbortRef = useRef<AbortController | null>(null)
const toast = useToast() const toast = useToast()
const { t, language } = useTranslation() const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore() const { hasMapsKey } = useAuthStore()
@@ -101,6 +124,73 @@ export default function PlaceFormModal({
setPendingFiles([]) setPendingFiles([])
}, [place, prefillCoords, isOpen]) }, [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) => { const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value })) setForm(prev => ({ ...prev, [field]: value }))
} }
@@ -111,7 +201,7 @@ export default function PlaceFormModal({
try { try {
// Detect Google Maps URLs and resolve them directly // Detect Google Maps URLs and resolve them directly
const trimmed = mapsSearch.trim() 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) const resolved = await mapsApi.resolveUrl(trimmed)
if (resolved.lat && resolved.lng) { if (resolved.lat && resolved.lng) {
setForm(prev => ({ setForm(prev => ({
@@ -152,6 +242,56 @@ export default function PlaceFormModal({
setMapsSearch('') 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 () => { const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return if (!newCategoryName.trim()) return
try { try {
@@ -229,24 +369,56 @@ export default function PlaceFormModal({
{t('places.osmActive')} {t('places.osmActive')}
</p> </p>
)} )}
<div className="flex gap-2"> <div className="relative">
<input <div className="flex gap-2">
type="text" <input
value={mapsSearch} type="text"
onChange={e => setMapsSearch(e.target.value)} value={mapsSearch}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())} onChange={e => setMapsSearch(e.target.value)}
placeholder={t('places.mapsSearchPlaceholder')} onKeyDown={handleSearchKeyDown}
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" onBlur={() => setTimeout(() => setAcSuggestions([]), 150)}
/> onFocus={() => {
<button if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) {
type="button" fetchSuggestions(mapsSearch.trim())
onClick={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" 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 ? '...' : <Search className="w-4 h-4" />} />
</button> <button
type="button"
onClick={() => { 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 ? '...' : <Search className="w-4 h-4" />}
</button>
</div>
{/* Autocomplete dropdown */}
{acSuggestions.length > 0 && (
<div className="absolute left-0 right-0 z-20 mt-1 bg-white rounded-lg border border-slate-200 shadow-lg overflow-hidden">
{acSuggestions.map((s, idx) => (
<button
key={s.placeId}
type="button"
onMouseDown={() => 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'
}`}
>
<div className="font-medium text-sm">{s.mainText}</div>
{s.secondaryText && (
<div className="text-xs text-slate-500 truncate">{s.secondaryText}</div>
)}
</button>
))}
</div>
)}
</div> </div>
{/* Search results (populated after full search) */}
{mapsResults.length > 0 && ( {mapsResults.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2"> <div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
{mapsResults.map((result, idx) => ( {mapsResults.map((result, idx) => (
@@ -267,14 +439,21 @@ export default function PlaceFormModal({
{/* Name */} {/* Name */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label> <label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
<input <div className="relative">
type="text" <input
value={form.name} type="text"
onChange={e => handleChange('name', e.target.value)} value={form.name}
required onChange={e => handleChange('name', e.target.value)}
placeholder={t('places.formNamePlaceholder')} required
className="form-input" placeholder={t('places.formNamePlaceholder')}
/> className="form-input"
/>
{isSearchingMaps && (
<div className="absolute right-2.5 top-0 bottom-0 flex items-center" role="status" aria-label={t('places.loadingDetails')}>
<Loader2 className="w-4 h-4 animate-spin text-slate-400" aria-hidden="true" />
</div>
)}
</div>
</div> </div>
{/* Description */} {/* Description */}
@@ -285,7 +464,20 @@ export default function PlaceFormModal({
onChange={e => handleChange('description', e.target.value)} onChange={e => handleChange('description', e.target.value)}
rows={2} rows={2}
placeholder={t('places.formDescriptionPlaceholder')} placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'none' }} className="form-input" style={{ resize: 'vertical' }}
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formNotes')}</label>
<textarea
value={form.notes}
onChange={e => handleChange('notes', e.target.value)}
rows={3}
maxLength={2000}
placeholder={t('places.formNotesPlaceholder')}
className="form-input" style={{ resize: 'vertical' }}
/> />
</div> </div>
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' 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 Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' 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' 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 */} {/* Description / Summary */}
{(place.description || place.notes || googleDetails?.summary) && ( {(place.description || googleDetails?.summary) && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}> <div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.notes || googleDetails?.summary || ''}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
</div>
)}
{/* Notes */}
{place.notes && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
</div> </div>
)} )}
@@ -582,7 +589,7 @@ export default function PlaceInspector({
{filesExpanded && placeFiles.length > 0 && ( {filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => ( {placeFiles.map(f => (
<button key={f.id} onClick={async () => { 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' }}> <button key={f.id} onClick={() => 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/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />} {(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span> <span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>} {f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
@@ -601,7 +608,7 @@ export default function PlaceInspector({
{selectedDayId && ( {selectedDayId && (
assignmentInDay ? ( assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />} <ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">Remove</span></>} /> label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">{t('inspector.remove')}</span></>} />
) : ( ) : (
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} /> <ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
) )
@@ -611,7 +618,7 @@ export default function PlaceInspector({
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} /> label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)} )}
{!googleDetails?.google_maps_url && place.lat && place.lng && ( {!googleDetails?.google_maps_url && place.lat && place.lng && (
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />} <ActionButton 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')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">Google Maps</span>} /> label={<span className="hidden sm:inline">Google Maps</span>} />
)} )}
{(place.website || googleDetails?.website) && ( {(place.website || googleDetails?.website) && (
@@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore'; import { usePermissionsStore } from '../../store/permissionsStore';
import { placesApi } from '../../api/client';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
@@ -432,32 +433,29 @@ describe('Mobile day-picker (portal)', () => {
// ── GPX import ──────────────────────────────────────────────────────────────── // ── GPX import ────────────────────────────────────────────────────────────────
describe('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(); const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} />); render(<PlacesSidebar {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement; await user.click(screen.getByText(/Import file/i));
expect(fileInput).toBeTruthy(); expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument();
const clickSpy = vi.spyOn(fileInput, 'click');
await user.click(screen.getByText(/GPX/i));
expect(clickSpy).toHaveBeenCalled();
}); });
it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => { it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => {
server.use( const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
http.post('/api/trips/1/places/import/gpx', () =>
HttpResponse.json({ count: 2, places: [{ id: 10 }, { id: 11 }] })
),
);
const loadTrip = vi.fn().mockResolvedValue(undefined); const loadTrip = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadTrip }); seedStore(useTripStore, { loadTrip });
const addToast = vi.fn(); const addToast = vi.fn();
(window as any).__addToast = addToast; (window as any).__addToast = addToast;
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />); render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
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' }); const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' });
await act(async () => { await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } }); fireEvent.change(fileInput, { target: { files: [file] } });
}); });
await user.click(screen.getByRole('button', { name: /^import$/i }));
await waitFor(() => { await waitFor(() => {
expect(addToast).toHaveBeenCalledWith( expect(addToast).toHaveBeenCalledWith(
expect.stringContaining('2'), expect.stringContaining('2'),
@@ -465,6 +463,7 @@ describe('GPX import', () => {
undefined, undefined,
); );
}); });
importSpy.mockRestore();
}); });
}); });
+133 -64
View File
@@ -1,18 +1,18 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState, useRef, useMemo, useCallback } from 'react' import { useState, useMemo, useEffect, useRef } from 'react'
import DOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client' import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useAddonStore } from '../../store/addonStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types' import type { Place, Category, Day, AssignmentsMap } from '../../types'
import FileImportModal from './FileImportModal'
interface PlacesSidebarProps { interface PlacesSidebarProps {
tripId: number tripId: number
@@ -28,7 +28,7 @@ interface PlacesSidebarProps {
onDeletePlace: (placeId: number) => void onDeletePlace: (placeId: number) => void
days: Day[] days: Day[]
isMobile: boolean isMobile: boolean
onCategoryFilterChange?: (categoryId: string) => void onCategoryFilterChange?: (categoryIds: Set<string>) => void
onPlacesFilterChange?: (filter: string) => void onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
} }
@@ -40,50 +40,77 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
const ctxMenu = useContextMenu() const ctxMenu = useContextMenu()
const gpxInputRef = useRef<HTMLInputElement>(null)
const trip = useTripStore((s) => s.trip) const trip = useTripStore((s) => s.trip)
const loadTrip = useTripStore((s) => s.loadTrip) const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo() const can = useCanDo()
const canEditPlaces = can('place_edit', trip) const canEditPlaces = can('place_edit', trip)
const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import'))
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => { const [fileImportOpen, setFileImportOpen] = useState(false)
const file = e.target.files?.[0] const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
if (!file) return const [sidebarDragOver, setSidebarDragOver] = useState(false)
e.target.value = '' const sidebarDragCounter = useRef(0)
try {
const result = await placesApi.importGpx(tripId, file) const handleSidebarDragEnter = (e: React.DragEvent) => {
await loadTrip(tripId) if (!canEditPlaces) return
toast.success(t('places.gpxImported', { count: result.count })) e.preventDefault()
if (result.places?.length > 0) { sidebarDragCounter.current++
const importedIds: number[] = result.places.map((p: { id: number }) => p.id) setSidebarDragOver(true)
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 [googleListOpen, setGoogleListOpen] = useState(false) const handleSidebarDragOver = (e: React.DragEvent) => {
const [googleListUrl, setGoogleListUrl] = useState('') if (!canEditPlaces) return
const [googleListLoading, setGoogleListLoading] = useState(false) e.preventDefault()
}
const handleGoogleListImport = async () => { const handleSidebarDragLeave = () => {
if (!googleListUrl.trim()) return sidebarDragCounter.current--
setGoogleListLoading(true) 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 { 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) await loadTrip(tripId)
toast.success(t('places.googleListImported', { count: result.count, list: result.listName })) if (result.count === 0 && result.skipped > 0) {
setGoogleListOpen(false) toast.warning(t('places.importAllSkipped'))
setGoogleListUrl('') } else {
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
}
setListImportOpen(false)
setListImportUrl('')
if (result.places?.length > 0) { if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id) 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) { for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {} try { await placesApi.delete(tripId, id) } catch {}
} }
@@ -91,9 +118,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
}) })
} }
} catch (err: any) { } 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 { } finally {
setGoogleListLoading(false) setListImportLoading(false)
} }
} }
@@ -105,8 +132,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
setCategoryFiltersLocal(prev => { setCategoryFiltersLocal(prev => {
const next = new Set(prev) const next = new Set(prev)
if (next.has(catId)) next.delete(catId); else next.add(catId) if (next.has(catId)) next.delete(catId); else next.add(catId)
// Notify parent with first selected or empty onCategoryFilterChange?.(next)
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
return next return next
}) })
} }
@@ -131,7 +157,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}> <div
onDragEnter={handleSidebarDragEnter}
onDragOver={handleSidebarDragOver}
onDragLeave={handleSidebarDragLeave}
onDrop={handleSidebarDrop}
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}
>
{sidebarDragOver && (
<div style={{
position: 'absolute', inset: 0, zIndex: 10,
background: 'color-mix(in srgb, var(--accent) 12%, transparent)',
border: '2px dashed var(--accent)',
borderRadius: 4,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 10, pointerEvents: 'none',
}}>
<Upload size={28} strokeWidth={1.5} color="var(--accent)" />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)' }}>{t('places.sidebarDrop')}</span>
</div>
)}
{/* Kopfbereich */} {/* Kopfbereich */}
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}> <div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
{canEditPlaces && <button {canEditPlaces && <button
@@ -146,10 +191,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')} <Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>} </button>}
{canEditPlaces && <> {canEditPlaces && <>
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}> <div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button <button
onClick={() => gpxInputRef.current?.click()} onClick={() => setFileImportOpen(true)}
style={{ style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8, flex: 1, padding: '5px 12px', borderRadius: 8,
@@ -158,10 +202,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}} }}
> >
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')} <Upload size={11} strokeWidth={2} /> {t('places.importFile')}
</button> </button>
<button <button
onClick={() => setGoogleListOpen(true)} onClick={() => setListImportOpen(true)}
style={{ style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8, flex: 1, padding: '5px 12px', borderRadius: 8,
@@ -170,7 +214,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
cursor: 'pointer', fontFamily: 'inherit', cursor: 'pointer', fontFamily: 'inherit',
}} }}
> >
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')} <MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
</button> </button>
</div> </div>
</>} </>}
@@ -257,7 +301,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
) )
})} })}
{categoryFilters.size > 0 && ( {categoryFilters.size > 0 && (
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{ <button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.(new Set()) }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer', width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)', 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) }, canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) }, 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.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 }, { divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])} ])}
@@ -381,7 +425,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
> >
<div <div
onClick={e => e.stopPropagation()} onClick={e => 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)' }}
> >
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}> <div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div> <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
@@ -448,9 +492,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
</div>, </div>,
document.body document.body
)} )}
{googleListOpen && ReactDOM.createPortal( {listImportOpen && ReactDOM.createPortal(
<div <div
onClick={() => { 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 }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
> >
<div <div
@@ -458,17 +502,35 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }} style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
> >
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}> <div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
{t('places.importGoogleList')} {t('places.importList')}
</div> </div>
{hasMultipleListImportProviders && (
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
{availableListImportProviders.map(provider => (
<button
key={provider}
onClick={() => 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')}
</button>
))}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}> <div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
{t('places.googleListHint')} {t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
</div> </div>
<input <input
type="text" type="text"
value={googleListUrl} value={listImportUrl}
onChange={e => setGoogleListUrl(e.target.value)} onChange={e => setListImportUrl(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }} onKeyDown={e => { if (e.key === 'Enter' && !listImportLoading) handleListImport() }}
placeholder="https://maps.app.goo.gl/..." placeholder={listImportProvider === 'google' ? 'https://maps.app.goo.gl/...' : 'https://naver.me/...'}
autoFocus autoFocus
style={{ style={{
width: '100%', padding: '10px 14px', borderRadius: 10, width: '100%', padding: '10px 14px', borderRadius: 10,
@@ -479,7 +541,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
/> />
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button <button
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }} onClick={() => { setListImportOpen(false); setListImportUrl('') }}
style={{ style={{
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
@@ -489,23 +551,30 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={handleGoogleListImport} onClick={handleListImport}
disabled={!googleListUrl.trim() || googleListLoading} disabled={!listImportUrl.trim() || listImportLoading}
style={{ style={{
padding: '8px 16px', borderRadius: 10, border: 'none', padding: '8px 16px', borderRadius: 10, border: 'none',
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)', background: !listImportUrl.trim() || listImportLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)', color: !listImportUrl.trim() || listImportLoading ? 'var(--text-faint)' : 'var(--accent-text)',
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer', fontSize: 13, fontWeight: 500, cursor: !listImportUrl.trim() || listImportLoading ? 'default' : 'pointer',
fontFamily: 'inherit', fontFamily: 'inherit',
}} }}
> >
{googleListLoading ? t('common.loading') : t('common.import')} {listImportLoading ? t('common.loading') : t('common.import')}
</button> </button>
</div> </div>
</div> </div>
</div>, </div>,
document.body document.body
)} )}
<FileImportModal
isOpen={fileImportOpen}
onClose={() => { setFileImportOpen(false); setSidebarDropFile(null) }}
tripId={tripId}
pushUndo={pushUndo}
initialFile={sidebarDropFile}
/>
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} /> <ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div> </div>
) )
@@ -10,7 +10,7 @@ import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker' 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' import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
@@ -587,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}> <div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> <FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span> <span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={async (e) => { 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' }}><ExternalLink size={11} /></a> <a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => { <button type="button" onClick={async () => {
// Always unlink, never delete the file // Always unlink, never delete the file
// Clear primary reservation_id if it points to this reservation // Clear primary reservation_id if it points to this reservation
@@ -10,7 +10,7 @@ import {
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users, Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react' } from 'lucide-react'
import { getAuthUrl } from '../../api/authUrl' import { openFile } from '../../utils/fileDownload'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry { interface AssignmentLookupEntry {
@@ -253,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}> <div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
{attachedFiles.map(f => ( {attachedFiles.map(f => (
<a key={f.id} href="#" onClick={async (e) => { 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' }}> <a key={f.id} href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} /> <FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span> <span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</a> </a>
@@ -142,7 +142,7 @@ export default function AccountTab(): React.ReactElement {
await updateProfile({ username, email }) await updateProfile({ username, email })
toast.success(t('settings.toast.profileSaved')) toast.success(t('settings.toast.profileSaved'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error') toast.error(err instanceof Error ? err.message : t('common.error'))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -34,7 +34,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
onClick={async () => { onClick={async () => {
try { try {
await updateSetting('dark_mode', opt.value) 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={{ style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
@@ -63,7 +63,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={opt.value} key={opt.value}
onClick={async () => { onClick={async () => {
try { await updateSetting('language', opt.value) } 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={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -94,7 +94,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
onClick={async () => { onClick={async () => {
setTempUnit(opt.value) setTempUnit(opt.value)
try { await updateSetting('temperature_unit', 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={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -124,7 +124,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={opt.value} key={opt.value}
onClick={async () => { onClick={async () => {
try { await updateSetting('time_format', opt.value) } 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={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -154,7 +154,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={String(opt.value)} key={String(opt.value)}
onClick={async () => { onClick={async () => {
try { await updateSetting('route_calculation', opt.value) } 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={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -184,7 +184,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={String(opt.value)} key={String(opt.value)}
onClick={async () => { onClick={async () => {
try { await updateSetting('blur_booking_codes', opt.value) } 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={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -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 { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
import { useAddonStore } from '../../store/addonStore'; import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories'; import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import IntegrationsTab from './IntegrationsTab'; import IntegrationsTab from './IntegrationsTab';
function enableMcp() { function enableMcp() {
@@ -40,6 +41,8 @@ beforeEach(() => {
server.use( server.use(
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })), http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })), 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'); 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(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
// Config is collapsed by default — no <pre> 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'); const preEl = document.querySelector('pre');
expect(preEl).not.toBeNull(); expect(preEl).not.toBeNull();
expect(preEl!.textContent).toContain('mcpServers'); expect(preEl!.textContent).toContain('mcpServers');
}); });
it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => { it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => {
const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
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.'); await screen.findByText('No tokens yet. Create one to connect MCP clients.');
}); });
@@ -95,8 +106,11 @@ describe('IntegrationsTab', () => {
}), }),
), ),
); );
const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('My Token'); await screen.findByText('My Token');
await screen.findByText('Other Token'); await screen.findByText('Other Token');
}); });
@@ -106,6 +120,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 }); const createBtn = screen.getByRole('button', { name: /Create New Token/i });
await user.click(createBtn); await user.click(createBtn);
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
@@ -116,6 +131,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i }); const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
@@ -127,6 +143,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -153,6 +170,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -182,6 +200,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test'); await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test');
@@ -206,6 +225,8 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me'); await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.'); 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(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me'); await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal // 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(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Cancel Token'); await screen.findByText('Cancel Token');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
await screen.findByRole('button', { name: /^Cancel$/i }); await screen.findByRole('button', { name: /^Cancel$/i });
@@ -319,6 +344,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); 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 user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -328,4 +354,301 @@ describe('IntegrationsTab', () => {
expect(postCalled).toBe(true); 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(<IntegrationsTab />);
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(<IntegrationsTab />);
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(<IntegrationsTab />);
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(<IntegrationsTab />);
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(<IntegrationsTab />);
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(<IntegrationsTab />);
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(<IntegrationsTab />);
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(<IntegrationsTab />);
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(<IntegrationsTab />);
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(<><ToastContainer /><IntegrationsTab /></>);
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(<IntegrationsTab />);
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(<><ToastContainer /><IntegrationsTab /></>);
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(<IntegrationsTab />);
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(<><ToastContainer /><IntegrationsTab /></>);
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);
});
}); });
@@ -1,12 +1,87 @@
import Section from './Section' import Section from './Section'
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react' import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
import { authApi } from '../../api/client' import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection' 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 { interface McpToken {
id: number id: number
@@ -26,6 +101,28 @@ export default function IntegrationsTab(): React.ReactElement {
loadAddons() loadAddons()
}, [loadAddons]) }, [loadAddons])
// OAuth clients state
const [oauthClients, setOauthClients] = useState<OAuthClient[]>([])
const [oauthSessions, setOauthSessions] = useState<OAuthSession[]>([])
const [oauthCreateOpen, setOauthCreateOpen] = useState(false)
const [oauthNewName, setOauthNewName] = useState('')
const [oauthNewUris, setOauthNewUris] = useState('')
const [oauthNewScopes, setOauthNewScopes] = useState<string[]>([])
const [oauthCreating, setOauthCreating] = useState(false)
const [oauthCreatedClient, setOauthCreatedClient] = useState<OAuthClient | null>(null)
const [oauthDeleteId, setOauthDeleteId] = useState<string | null>(null)
const [oauthRevokeId, setOauthRevokeId] = useState<number | null>(null)
const [oauthRotateId, setOauthRotateId] = useState<string | null>(null)
const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null)
const [oauthRotating, setOauthRotating] = useState(false)
// oauthScopesOpen is managed internally by ScopeGroupPicker
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
// MCP sub-tab state
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
const [configOpenOAuth, setConfigOpenOAuth] = useState(false)
const [configOpenToken, setConfigOpenToken] = useState(false)
// MCP state // MCP state
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([]) const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
const [mcpModalOpen, setMcpModalOpen] = useState(false) const [mcpModalOpen, setMcpModalOpen] = useState(false)
@@ -34,8 +131,26 @@ export default function IntegrationsTab(): React.ReactElement {
const [mcpCreating, setMcpCreating] = useState(false) const [mcpCreating, setMcpCreating] = useState(false)
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null) const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null) const [copiedKey, setCopiedKey] = useState<string | null>(null)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
}, [])
const mcpEndpoint = `${window.location.origin}/mcp` const mcpEndpoint = `${window.location.origin}/mcp`
const mcpJsonConfigOAuth = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--static-oauth-client-info",
"{\\"client_id\\": \\"<your_client_id>\\", \\"client_secret\\": \\"<your_client_secret>\\"}"
]
}
}
}`
const mcpJsonConfig = `{ const mcpJsonConfig = `{
"mcpServers": { "mcpServers": {
"trek": { "trek": {
@@ -85,10 +200,72 @@ export default function IntegrationsTab(): React.ReactElement {
const handleCopy = (text: string, key: string) => { const handleCopy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
setCopiedKey(key) 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 ( return (
<> <>
<PhotoProvidersSection /> <PhotoProvidersSection />
@@ -109,63 +286,217 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
</div> </div>
{/* JSON config box */} {/* Sub-tab bar */}
<div> <div className="flex gap-1 rounded-lg p-1" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<div className="flex items-center justify-between mb-1.5"> <button
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label> onClick={() => setActiveMcpTab('oauth')}
<button onClick={() => handleCopy(mcpJsonConfig, 'json')} className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
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" activeMcpTab === 'oauth' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> }`}>
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />} {t('settings.oauth.clients')}
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')} </button>
</button> <button
</div> onClick={() => setActiveMcpTab('apitokens')}
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2 ${
{mcpJsonConfig} activeMcpTab === 'apitokens' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
</pre> }`}>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p> {t('settings.mcp.apiTokens')}
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{ background: 'rgba(245,158,11,0.15)', color: '#b45309', border: '1px solid rgba(245,158,11,0.4)' }}>
Deprecated
</span>
</button>
</div> </div>
{/* Token list */} {/* OAuth 2.1 Clients tab */}
<div> {activeMcpTab === 'oauth' && (
<div className="flex items-center justify-between mb-2"> <>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label> {/* JSON config — OAuth (collapsible) */}
<button onClick={() => { 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' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button>
</div>
{mcpTokens.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
{t('settings.mcp.noTokens')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{mcpTokens.map((token, i) => ( <button
<div key={token.id} className="flex items-center gap-3 px-4 py-3" onClick={() => setConfigOpenOAuth(o => !o)}
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}> className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
<div className="flex-1 min-w-0"> style={{ background: 'var(--bg-secondary)' }}>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p> <span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}> {configOpenOAuth ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
{token.token_prefix}... </button>
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span> {configOpenOAuth && (
{token.last_used_at && ( <div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span> <div className="flex justify-end mb-1.5">
)} <button onClick={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')}
</p> 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' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-oauth' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div> </div>
<button onClick={() => setMcpDeleteId(token.id)} <pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20" {mcpJsonConfigOAuth}
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}> </pre>
<Trash2 className="w-4 h-4" /> <p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHintOAuth')}</p>
</button>
</div> </div>
))} )}
</div> </div>
)}
</div> <div>
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
<div className="flex justify-end mb-2">
<button onClick={() => { 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">
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
</button>
</div>
{oauthClients.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
{t('settings.oauth.noClients')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{oauthClients.map((client, i) => (
<div key={client.id} className="px-4 py-3"
style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex items-center gap-3">
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.clientId')}: {client.client_id}
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
<span key={s} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>{s}</span>
))}
{client.allowed_scopes.length > 5 && (
<button
onClick={() => 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}`}
</button>
)}
</div>
</div>
<button onClick={() => 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')}>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={() => 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')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Active OAuth Sessions */}
{oauthSessions.length > 0 && (
<div>
<label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{oauthSessions.map((session, i) => (
<div key={session.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
<span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
</p>
</div>
<button onClick={() => 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')}
</button>
</div>
))}
</div>
</div>
)}
</>
)}
{/* API Tokens tab (deprecated) */}
{activeMcpTab === 'apitokens' && (
<>
<div className="flex items-baseline gap-2 px-3 py-2.5 rounded-lg" style={{ background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.3)' }}>
<span className="text-amber-500 flex-shrink-0 leading-none"></span>
<p className="text-xs" style={{ color: '#92400e' }}>{t('settings.mcp.apiTokensDeprecated')}</p>
</div>
{/* JSON config — API Token (collapsible) */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<button
onClick={() => 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)' }}>
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
{configOpenToken ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
</button>
{configOpenToken && (
<div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex justify-end mb-1.5">
<button onClick={() => 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' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-token' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{mcpJsonConfig}
</pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
</div>
)}
</div>
<div className="flex justify-end">
<button onClick={() => { 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)' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button>
</div>
{mcpTokens.length === 0 ? (
<p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.mcp.noTokens')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{mcpTokens.map((token, i) => (
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{token.token_prefix}...
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
{token.last_used_at && (
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
)}
</p>
</div>
<button onClick={() => 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')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</>
)}
</Section> </Section>
)} )}
@@ -182,7 +513,7 @@ export default function IntegrationsTab(): React.ReactElement {
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)} <input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} 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)' }} style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus /> autoFocus />
</div> </div>
@@ -192,8 +523,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating} <button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')} {mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
</button> </button>
</div> </div>
@@ -217,8 +547,7 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }} <button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{t('settings.mcp.modal.done')} {t('settings.mcp.modal.done')}
</button> </button>
</div> </div>
@@ -248,6 +577,216 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
</div> </div>
)} )}
{/* Create OAuth Client modal */}
{oauthCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
<div className="rounded-xl shadow-xl w-full max-w-lg p-6 space-y-4 overflow-y-auto max-h-[90vh]" style={{ background: 'var(--bg-card)' }}>
{!oauthCreatedClient ? (
<>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createTitle')}</h3>
<div>
<label className="block text-xs font-medium mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.presets')}</label>
<div className="flex flex-wrap gap-1.5">
{OAUTH_PRESETS.map(preset => (
<button
key={preset.id}
type="button"
onClick={() => {
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}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.clientName')}</label>
<input type="text" value={oauthNewName} onChange={e => 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 />
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
rows={3}
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p>
<ScopeGroupPicker selected={oauthNewScopes} onChange={setOauthNewScopes} />
</div>
<div className="flex gap-2 justify-end pt-1">
<button onClick={() => setOauthCreateOpen(false)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleCreateOAuthClient}
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
</button>
</div>
</>
) : (
<>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.createdWarning')}</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientId')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthCreatedClient.client_id}
</code>
<button onClick={() => 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' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthCreatedClient.client_secret}
</code>
<button onClick={() => 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' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
</div>
<div className="flex justify-end">
<button onClick={() => { 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')}
</button>
</div>
</>
)}
</div>
</div>
)}
{/* Delete OAuth Client confirm */}
{oauthDeleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthDeleteId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.deleteClient')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.deleteClientMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => 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')}
</button>
</div>
</div>
</div>
)}
{/* Rotate OAuth Client Secret confirm */}
{oauthRotateId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthRotateId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecret')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthRotateId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => 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')}
</button>
</div>
</div>
</div>
)}
{/* Rotated Secret display */}
{oauthRotatedSecret !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}>
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecretDoneTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretDoneWarning')}</p>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthRotatedSecret}
</code>
<button onClick={() => 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' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
<div className="flex justify-end">
<button onClick={() => 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')}
</button>
</div>
</div>
</div>
)}
{/* Revoke OAuth Session confirm */}
{oauthRevokeId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthRevokeId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.revokeSession')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.revokeSessionMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthRevokeId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => 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')}
</button>
</div>
</div>
</div>
)}
</> </>
) )
} }
@@ -74,7 +74,7 @@ export default function MapSettingsTab(): React.ReactElement {
}) })
toast.success(t('settings.toast.mapSaved')) toast.success(t('settings.toast.mapSaved'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error') toast.error(err instanceof Error ? err.message : t('common.error'))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -35,14 +35,14 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () => new Promise(() => {})), http.get('/api/notifications/preferences', () => new Promise(() => {})),
); );
render(<NotificationsTab />); render(<NotificationsTab />);
expect(screen.getByText('Loading')).toBeInTheDocument(); expect(screen.getByText('Loading...')).toBeInTheDocument();
}); });
it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => { it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => {
render(<NotificationsTab />); render(<NotificationsTab />);
// The event label is translated; fallback is the key itself // The event label is translated; fallback is the key itself
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// Should render a toggle (ToggleSwitch renders a button) // Should render a toggle (ToggleSwitch renders a button)
const toggles = await screen.findAllByRole('button'); const toggles = await screen.findAllByRole('button');
@@ -52,7 +52,7 @@ describe('NotificationsTab', () => {
it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => { it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// inapp channel header should appear (either translated or raw key) // inapp channel header should appear (either translated or raw key)
const headers = screen.getAllByText(/inapp|in.?app/i); const headers = screen.getAllByText(/inapp|in.?app/i);
@@ -72,7 +72,7 @@ describe('NotificationsTab', () => {
); );
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// Should show noChannels message (translated or key) // Should show noChannels message (translated or key)
const noChannelEl = await screen.findByText(/no.*channel|noChannels/i); const noChannelEl = await screen.findByText(/no.*channel|noChannels/i);
@@ -97,7 +97,7 @@ describe('NotificationsTab', () => {
); );
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// A dash should appear for non-implemented combos // A dash should appear for non-implemented combos
const dashes = await screen.findAllByText('—'); const dashes = await screen.findAllByText('—');
@@ -116,7 +116,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// minimalMatrix has inapp:true and email:false for trip_invite // minimalMatrix has inapp:true and email:false for trip_invite
@@ -144,7 +144,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { 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" // 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) // After the error, the toggle should revert back (still rendered in the DOM)
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.queryByText('Saving')).not.toBeInTheDocument(); expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
}); });
// The toggle should still be present (not removed on error) // The toggle should still be present (not removed on error)
@@ -178,20 +178,20 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const toggleButtons = await screen.findAllByRole('button'); const toggleButtons = await screen.findAllByRole('button');
await user.click(toggleButtons[0]); await user.click(toggleButtons[0]);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Saving')).toBeInTheDocument(); expect(screen.getByText('Saving...')).toBeInTheDocument();
}); });
resolveRequest(); resolveRequest();
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Saving')).not.toBeInTheDocument(); expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
}); });
}); });
@@ -209,7 +209,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// Webhook URL input should be present // Webhook URL input should be present
@@ -238,7 +238,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const input = await screen.findByRole('textbox'); const input = await screen.findByRole('textbox');
@@ -265,7 +265,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const input = await screen.findByRole('textbox'); const input = await screen.findByRole('textbox');
@@ -297,7 +297,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
await screen.findByRole('textbox'); await screen.findByRole('textbox');
@@ -330,7 +330,7 @@ describe('NotificationsTab', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const input = await screen.findByRole('textbox'); const input = await screen.findByRole('textbox');
@@ -371,7 +371,7 @@ describe('NotificationsTab', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const input = await screen.findByRole('textbox'); const input = await screen.findByRole('textbox');
@@ -107,7 +107,7 @@ export default function NotificationsTab(): React.ReactElement {
} }
const renderContent = () => { const renderContent = () => {
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading</p> if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
if (visibleChannels.length === 0) { if (visibleChannels.length === 0) {
return ( return (
@@ -119,7 +119,7 @@ export default function NotificationsTab(): React.ReactElement {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving</p>} {saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('common.saving')}</p>}
{matrix.available_channels.webhook && ( {matrix.available_channels.webhook && (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}> <div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}> <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
@@ -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<CachedTripRow[]>([])
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 (
<Section title="Offline Cache" icon={Database}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Stats row */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<Stat label="Cached trips" value={rows.length} />
<Stat label="Pending changes" value={pendingCount} />
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleResync}
disabled={syncing || !navigator.onLine}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
cursor: syncing || !navigator.onLine ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 500, opacity: !navigator.onLine ? 0.5 : 1,
}}
>
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
{syncing ? 'Syncing…' : 'Re-sync now'}
</button>
<button
onClick={handleClear}
disabled={clearing || rows.length === 0}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: '#ef4444',
cursor: clearing || rows.length === 0 ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 500, opacity: rows.length === 0 ? 0.5 : 1,
}}
>
<Trash2 size={14} />
Clear cache
</button>
</div>
{/* Cached trip list */}
{loading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading</p>
) : rows.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>
No trips cached yet. Connect to internet to sync.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{rows.map(({ trip, meta, placeCount, fileCount }) => (
<div
key={trip.id}
style={{
padding: '10px 14px', borderRadius: 8,
border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)',
display: 'flex', flexDirection: 'column', gap: 2,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
{trip.name}
</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
{meta.lastSyncedAt
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: '—'}
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{formatDate(trip.start_date)} {formatDate(trip.end_date)}
{' · '}
{placeCount} place{placeCount !== 1 ? 's' : ''}
{' · '}
{fileCount} file{fileCount !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
)}
</div>
</Section>
)
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div style={{
padding: '8px 14px', borderRadius: 8,
border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', minWidth: 100,
}}>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)' }}>{value}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{label}</div>
</div>
)
}
@@ -11,6 +11,7 @@ interface ProviderField {
label: string label: string
input_type: string input_type: string
placeholder?: string | null placeholder?: string | null
hint?: string | null
required: boolean required: boolean
secret: boolean secret: boolean
settings_key?: string | null settings_key?: string | null
@@ -71,6 +72,10 @@ export default function PhotoProvidersSection(): React.ReactElement {
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {}
for (const field of getProviderFields(provider)) { for (const field of getProviderFields(provider)) {
const payloadKey = field.payload_key || field.settings_key || field.key 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() const value = (values[field.key] || '').trim()
if (field.secret && !value) continue if (field.secret && !value) continue
payload[payloadKey] = value payload[payloadKey] = value
@@ -102,6 +107,18 @@ export default function PhotoProvidersSection(): React.ReactElement {
const cfg = getProviderConfig(provider) const cfg = getProviderConfig(provider)
const fields = getProviderFields(provider) const fields = getProviderFields(provider)
// Seed checkbox defaults before the async settings load resolves
const checkboxDefaults: Record<string, string> = {}
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) { if (cfg.settings_get) {
apiClient.get(cfg.settings_get).then(res => { apiClient.get(cfg.settings_get).then(res => {
if (isCancelled) return if (isCancelled) return
@@ -112,7 +129,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
if (field.secret) continue if (field.secret) continue
const sourceKey = field.settings_key || field.payload_key || field.key const sourceKey = field.settings_key || field.payload_key || field.key
const rawValue = (res.data as Record<string, unknown>)[sourceKey] const rawValue = (res.data as Record<string, unknown>)[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 => ({ setProviderValues(prev => ({
...prev, ...prev,
@@ -198,14 +221,31 @@ export default function PhotoProvidersSection(): React.ReactElement {
<div className="space-y-3"> <div className="space-y-3">
{fields.map(field => ( {fields.map(field => (
<div key={`${provider.id}-${field.key}`}> <div key={`${provider.id}-${field.key}`}>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label> {field.input_type === 'checkbox' ? (
<input <label className="flex items-center gap-2 cursor-pointer select-none">
type={field.input_type || 'text'} <input
value={values[field.key] || ''} type="checkbox"
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)} checked={values[field.key] === 'true'}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" className="w-4 h-4 rounded border-slate-300 accent-slate-900"
/> />
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</label>
) : (
<>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
<input
type={field.input_type || 'text'}
value={values[field.key] || ''}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
/>
{field.hint && (
<p className="mt-1 text-xs text-slate-500">{t(`memories.${field.hint}`)}</p>
)}
</>
)}
</div> </div>
))} ))}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -213,7 +253,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
onClick={() => handleSaveProvider(provider)} onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(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" 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') : ''}
> >
<Save className="w-4 h-4" /> {t('common.save')} <Save className="w-4 h-4" /> {t('common.save')}
</button> </button>
@@ -221,18 +261,23 @@ export default function PhotoProvidersSection(): React.ReactElement {
onClick={() => handleTestProvider(provider)} onClick={() => handleTestProvider(provider)}
disabled={!canTest || testing} 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" 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 {testing
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />} : <Camera className="w-4 h-4" />}
{t('memories.testConnection')} {t('memories.testConnection')}
</button> </button>
{connected && ( {connected ? (
<span className="text-xs font-medium text-green-600 flex items-center gap-1"> <span className="text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" /> <span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')} {t('memories.connected')}
</span> </span>
) : (
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')}
</span>
)} )}
</div> </div>
</div> </div>
+4 -4
View File
@@ -105,7 +105,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return } if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any) addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) }) .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) // Get category count (non-done items)
@@ -479,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
due_date: dueDate || null, category: category || null, due_date: dueDate || null, category: category || null,
assigned_user_id: assignedUserId, priority, assigned_user_id: assignedUserId, priority,
} as any) } 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) setSaving(false)
} }
@@ -487,7 +487,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
try { try {
await deleteTodoItem(tripId, item.id) await deleteTodoItem(tripId, item.id)
onClose() 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' } 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, assigned_user_id: assignedUserId,
} as any) } as any)
if (item?.id) onCreated(item.id) 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) setSaving(false)
} }
+51 -8
View File
@@ -46,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [uploadingCover, setUploadingCover] = useState(false) const [uploadingCover, setUploadingCover] = useState(false)
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]) const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
const [selectedMembers, setSelectedMembers] = useState<number[]>([]) const [selectedMembers, setSelectedMembers] = useState<number[]>([])
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
const [memberSelectValue, setMemberSelectValue] = useState('') const [memberSelectValue, setMemberSelectValue] = useState('')
useEffect(() => { 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) if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {}) }).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]) }, [trip, isOpen])
@@ -365,12 +369,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div> </div>
)} )}
{/* Members — only for new trips */} {/* Members */}
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && ( {allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5"> <label className="block text-sm font-medium text-slate-700 mb-1.5">
<UserPlus className="inline w-4 h-4 mr-1" />{t('dashboard.addMembers')} <UserPlus className="inline w-4 h-4 mr-1" />{isEditing ? t('dashboard.addMembers') : t('dashboard.addMembers')}
</label> </label>
{/* Existing members (editing mode) */}
{isEditing && existingMembers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{existingMembers.map(m => (
<span key={m.id}
onClick={async () => {
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 && <X size={11} style={{ color: 'var(--text-faint)' }} />}
</span>
))}
</div>
)}
{/* Newly selected members (both modes) */}
{selectedMembers.length > 0 && ( {selectedMembers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{selectedMembers.map(uid => { {selectedMembers.map(uid => {
@@ -393,11 +423,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<CustomSelect <CustomSelect
value={memberSelectValue} value={memberSelectValue}
onChange={value => { onChange={async value => {
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') } 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')} 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 searchable
size="sm" size="sm"
/> />
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import { tripsApi, authApi, shareApi } from '../../api/client' import { tripsApi, authApi, shareApi } from '../../api/client'
import { useToast } from '../shared/Toast' 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 [copied, setCopied] = useState(false)
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false }) const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
const toast = useToast() const toast = useToast()
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
}, [])
useEffect(() => { useEffect(() => {
shareApi.getLink(tripId).then(d => { shareApi.getLink(tripId).then(d => {
@@ -77,7 +82,8 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
if (shareUrl) { if (shareUrl) {
navigator.clipboard.writeText(shareUrl) navigator.clipboard.writeText(shareUrl)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
} }
} }
@@ -151,7 +151,7 @@ describe('VacayCalendar', () => {
expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42) 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 user = userEvent.setup()
const toggleEntry = vi.fn().mockResolvedValue(undefined) const toggleEntry = vi.fn().mockResolvedValue(undefined)
@@ -168,10 +168,10 @@ describe('VacayCalendar', () => {
render(<VacayCalendar />) render(<VacayCalendar />)
// 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')) 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 () => { it('FE-COMP-VACAYCALENDAR-008: cell click in company mode calls toggleCompanyHoliday', async () => {
+30 -4
View File
@@ -1,7 +1,8 @@
import { useMemo, useState, useCallback } from 'react' import { useMemo, useState, useCallback, useEffect } from 'react'
import { useVacayStore } from '../../store/vacayStore' import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays' import { isWeekend } from './holidays'
import { tripsApi } from '../../api/client'
import VacayMonthCard from './VacayMonthCard' import VacayMonthCard from './VacayMonthCard'
import { Building2, MousePointer2 } from 'lucide-react' import { Building2, MousePointer2 } from 'lucide-react'
@@ -9,6 +10,30 @@ export default function VacayCalendar() {
const { t } = useTranslation() const { t } = useTranslation()
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore() const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
const [companyMode, setCompanyMode] = useState(false) const [companyMode, setCompanyMode] = useState(false)
const [tripDates, setTripDates] = useState<Set<string>>(new Set())
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await tripsApi.list()
const dates = new Set<string>()
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 companyHolidaySet = useMemo(() => {
const s = new Set() const s = new Set()
@@ -35,17 +60,16 @@ export default function VacayCalendar() {
await toggleCompanyHoliday(dateStr) await toggleCompanyHoliday(dateStr)
return return
} }
if (holidays[dateStr]) return
if (blockWeekends && isWeekend(dateStr, weekendDays)) return if (blockWeekends && isWeekend(dateStr, weekendDays)) return
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
await toggleEntry(dateStr, selectedUserId || undefined) 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) const selectedUser = users.find(u => u.id === selectedUserId)
return ( return (
<div> <div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
{Array.from({ length: 12 }, (_, i) => ( {Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard <VacayMonthCard
key={i} key={i}
@@ -59,6 +83,8 @@ export default function VacayCalendar() {
companyMode={companyMode} companyMode={companyMode}
blockWeekends={blockWeekends} blockWeekends={blockWeekends}
weekendDays={weekendDays} weekendDays={weekendDays}
tripDates={tripDates}
weekStart={plan?.week_start ?? 1}
/> />
))} ))}
</div> </div>
+47 -13
View File
@@ -23,22 +23,26 @@ interface VacayMonthCardProps {
companyMode: boolean companyMode: boolean
blockWeekends: boolean blockWeekends: boolean
weekendDays?: number[] weekendDays?: number[]
tripDates?: Set<string>
weekStart?: number
} }
export default function VacayMonthCard({ export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6] onCellClick, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1
}: VacayMonthCardProps) { }: VacayMonthCardProps) {
const { t, locale } = useTranslation() 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 monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
const weeks = useMemo(() => { const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1) const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate() const daysInMonth = new Date(year, month + 1, 0).getDate()
let startDow = firstDay.getDay() - 1 let startDow = firstDay.getDay() - weekStart
if (startDow < 0) startDow = 6 if (startDow < 0) startDow += 7
const cells = [] const cells = []
for (let i = 0; i < startDow; i++) cells.push(null) for (let i = 0; i < startDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d) 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 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 ( return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}> <div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
@@ -57,11 +66,16 @@ export default function VacayMonthCard({
</div> </div>
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
{weekdays.map((wd, i) => ( {weekdays.map((wd, i) => {
<div key={wd} className="text-center text-[10px] font-medium py-1" style={{ color: i >= 5 ? 'var(--text-faint)' : 'var(--text-muted)' }}> // Map column index back to JS day (0=Sun..6=Sat) to check if it's a weekend column
{wd} const jsDay = (i + weekStart) % 7
</div> const isWeekendCol = weekendDays.includes(jsDay)
))} return (
<div key={`${wd}-${i}`} className="text-center text-[10px] font-medium py-1" style={{ color: isWeekendCol ? 'var(--text-faint)' : 'var(--text-muted)' }}>
{wd}
</div>
)
})}
</div> </div>
<div> <div>
@@ -76,7 +90,8 @@ export default function VacayMonthCard({
const holiday = holidays[dateStr] const holiday = holidays[dateStr]
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr) const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
const dayEntries = entryMap[dateStr] || [] const dayEntries = entryMap[dateStr] || []
const isBlocked = !!holiday || (weekend && blockWeekends) || (isCompany && !companyMode) const isBlocked = (weekend && blockWeekends) || (isCompany && !companyMode)
const isToday = dateStr === todayStr
return ( return (
<div <div
@@ -122,9 +137,28 @@ export default function VacayMonthCard({
</div> </div>
)} )}
<span className="relative z-[1] text-[11px] font-medium" style={{ {tripDates?.has(dateStr) && (
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)', <span className="absolute top-[3px] right-[3px] w-[5px] h-[5px] rounded-full z-[2]" style={{ background: '#3b82f6' }} />
)}
<span className="relative z-[1] text-[11px]" style={{
fontWeight: dayEntries.length > 0 ? 700 : 500, fontWeight: dayEntries.length > 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} {day}
</span> </span>
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' 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 userEvent from '@testing-library/user-event'
import { render } from '../../../tests/helpers/render' import { render } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -75,17 +75,7 @@ describe('VacaySettings', () => {
render(<VacaySettings onClose={vi.fn()} />) render(<VacaySettings onClose={vi.fn()} />)
// Day buttons should be visible (Mon, Tue, Wed, Thu, Fri, Sat, Sun) // 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 const dayButtons = within(screen.getByTestId('weekend-days')).getAllByRole('button')
// 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')
)
// There should be 7 day buttons // There should be 7 day buttons
expect(dayButtons.length).toBe(7) expect(dayButtons.length).toBe(7)
}) })
@@ -98,14 +88,8 @@ describe('VacaySettings', () => {
}) })
render(<VacaySettings onClose={vi.fn()} />) render(<VacaySettings onClose={vi.fn()} />)
// When block_weekends is false, the day selector section is not rendered // When block_weekends is false, the weekend-days container is not rendered
// There should only be toggle buttons (4 toggles), no day buttons expect(screen.queryByTestId('weekend-days')).toBeNull()
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)
}) })
it('FE-COMP-VACAYSETTINGS-005: clicking an active weekend day removes it', async () => { it('FE-COMP-VACAYSETTINGS-005: clicking an active weekend day removes it', async () => {
+33 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' 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 { useVacayStore } from '../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../i18n' import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
@@ -51,7 +51,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{/* Weekend days selector */} {/* Weekend days selector */}
{plan.block_weekends !== false && ( {plan.block_weekends !== false && (
<div style={{ paddingLeft: 36 }}> <div data-testid="weekend-days" style={{ paddingLeft: 36 }}>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p> <p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{[ {[
@@ -85,6 +85,37 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
</div> </div>
)} )}
{/* Week start */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<CalendarDays size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{t('vacay.weekStart')}</span>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('vacay.weekStartHint')}</p>
</div>
</div>
<div style={{ paddingLeft: 36, marginTop: 8 }} className="flex gap-1.5">
{[
{ value: 1, label: t('vacay.mon') },
{ value: 0, label: t('vacay.sun') },
].map(({ value, label }) => {
const active = (plan.week_start ?? 1) === value
return (
<button key={value} onClick={() => 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}
</button>
)
})}
</div>
</div>
{/* Carry-over */} {/* Carry-over */}
<SettingToggle <SettingToggle
icon={ArrowRightLeft} icon={ArrowRightLeft}
+2 -2
View File
@@ -50,7 +50,7 @@ export default function Modal({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-start sm:items-center justify-center px-4 modal-backdrop" className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }} style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20, overflow: 'hidden' }}
onMouseDown={e => { mouseDownTarget.current = e.target }} onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => { onClick={e => {
@@ -61,7 +61,7 @@ export default function Modal({
<div <div
className={` className={`
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md} rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[calc(100vh-90px)] flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
animate-in fade-in zoom-in-95 duration-200 animate-in fade-in zoom-in-95 duration-200
`} `}
style={{ style={{
+14 -4
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react' import React, { useState, useCallback, useEffect, useRef } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react' import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
type ToastType = 'success' | 'error' | 'warning' | 'info' type ToastType = 'success' | 'error' | 'warning' | 'info'
@@ -28,18 +28,27 @@ const ICON_COLORS: Record<ToastType, string> = {
export function ToastContainer() { export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]) const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => {
timersRef.current.forEach(clearTimeout)
}
}, [])
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => { const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastIdCounter const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }]) setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) { if (duration > 0) {
setTimeout(() => { const t1 = setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => { const t2 = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts(prev => prev.filter(t => t.id !== id))
}, 400) }, 400)
timersRef.current.push(t2)
}, duration) }, duration)
timersRef.current.push(t1)
} }
return id return id
@@ -47,9 +56,10 @@ export function ToastContainer() {
const removeToast = useCallback((id: number) => { const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => { const t = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts(prev => prev.filter(t => t.id !== id))
}, 400) }, 400)
timersRef.current.push(t)
}, []) }, [])
useEffect(() => { useEffect(() => {

Some files were not shown because too many files have changed in this diff Show More