mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 05:11:46 +00:00
Merge remote-tracking branch 'origin/dev' into feat/indonesian-translation
This commit is contained in:
@@ -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
|
||||
@@ -7,10 +7,24 @@ on:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: 'Force bump line (auto = patch/finalize as today)'
|
||||
type: choice
|
||||
options: [auto, patch, minor, major]
|
||||
default: auto
|
||||
confirm_major:
|
||||
description: "Type MAJOR (all caps) to confirm a major release"
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: stable-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -20,48 +34,79 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
run: |
|
||||
# Check if this push is a merge commit from dev branch
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s)
|
||||
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
|
||||
git fetch --tags
|
||||
|
||||
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
|
||||
# Derive version from git tags — no package.json dependency
|
||||
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
|
||||
STABLE="${STABLE_TAG#v}"
|
||||
STABLE="${STABLE:-0.0.0}"
|
||||
|
||||
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
|
||||
|
||||
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
|
||||
|
||||
if [ "$BUMP_INPUT" = "major" ]; then
|
||||
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
|
||||
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
|
||||
exit 1
|
||||
fi
|
||||
NEW_VERSION="$((MAJOR + 1)).0.0"
|
||||
BUMP="major"
|
||||
elif [ "$BUMP_INPUT" = "minor" ]; then
|
||||
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
|
||||
BUMP="minor"
|
||||
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
|
||||
BUMP="minor"
|
||||
else
|
||||
elif [ "$BUMP_INPUT" = "patch" ]; then
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
|
||||
BUMP="patch"
|
||||
else
|
||||
# auto: finalize in-flight prerelease if one exists, else patch
|
||||
if [ -n "$PRE_TAG" ]; then
|
||||
PRE_BASE="${PRE_TAG#v}"
|
||||
PRE_BASE="${PRE_BASE%-pre.*}"
|
||||
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
|
||||
# Refuse to auto-finalize a major bump — it bypasses confirm_major
|
||||
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
|
||||
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
|
||||
exit 1
|
||||
fi
|
||||
# If prerelease base is strictly greater than stable, finalize it
|
||||
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
|
||||
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
|
||||
NEW_VERSION="$PRE_BASE"
|
||||
BUMP="finalize"
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
BUMP="patch"
|
||||
fi
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
BUMP="patch"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Bump type: $BUMP"
|
||||
|
||||
# Read current version
|
||||
CURRENT=$(node -p "require('./server/package.json').version")
|
||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||
|
||||
if [ "$BUMP" = "minor" ]; then
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
else
|
||||
PATCH=$((PATCH + 1))
|
||||
fi
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "$CURRENT → $NEW_VERSION ($BUMP)"
|
||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||
|
||||
# Update both package.json files
|
||||
# Update package.json files and Helm chart
|
||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
|
||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
@@ -100,6 +145,8 @@ jobs:
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||
no-cache: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.version-bump.outputs.version }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
@@ -140,14 +187,29 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
VERSION=${{ needs.version-bump.outputs.version }}
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
docker buildx imagetools create \
|
||||
-t mauriceboe/trek:latest \
|
||||
-t mauriceboe/trek:$VERSION \
|
||||
-t mauriceboe/nomad:latest \
|
||||
-t mauriceboe/nomad:$VERSION \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
-t "mauriceboe/trek:$VERSION" \
|
||||
"${digests[@]}"
|
||||
|
||||
- name: Inspect manifest
|
||||
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||
|
||||
release-helm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: version-bump
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Publish Helm chart
|
||||
uses: stefanprodan/helm-gh-pages@v1.7.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
charts_dir: charts
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Enforce PR Target Branch
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
@@ -9,6 +9,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Flag or clear wrong base branch
|
||||
@@ -63,14 +65,16 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
name: 'wrong-base-branch',
|
||||
});
|
||||
} catch {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'wrong-base-branch',
|
||||
color: 'd73a4a',
|
||||
description: 'PR is targeting the wrong base branch',
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status === 404) {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'wrong-base-branch',
|
||||
color: 'd73a4a',
|
||||
description: 'PR is targeting the wrong base branch',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
|
||||
@@ -27,6 +27,8 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@ structured API.
|
||||
## Table of Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
|
||||
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
|
||||
- [Authentication](#authentication)
|
||||
- [OAuth Scopes](#oauth-scopes)
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
@@ -22,22 +26,51 @@ structured API.
|
||||
### 1. Enable the MCP addon (admin)
|
||||
|
||||
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
|
||||
endpoint returns `404` and the MCP section does not appear in user settings.
|
||||
|
||||
### 2. Create an API token
|
||||
### 2. Connect your MCP client
|
||||
|
||||
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
|
||||
#### Option A: OAuth 2.1 (recommended)
|
||||
|
||||
1. Click **Create New Token**
|
||||
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
|
||||
3. **Copy the token immediately** — it is shown only once and cannot be recovered
|
||||
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
|
||||
management required — just provide the server URL:
|
||||
|
||||
Each user can create up to **10 tokens**.
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Configure your MCP client
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
|
||||
`claude_desktop_config.json`:
|
||||
**What happens automatically:**
|
||||
1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
|
||||
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
|
||||
|
||||
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
||||
> discovery to work correctly.
|
||||
|
||||
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
|
||||
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
|
||||
(`trekcs_` prefix) and fixed scopes that you define up front.
|
||||
|
||||
#### Option B: Static API Token (deprecated)
|
||||
|
||||
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
|
||||
|
||||
1. Go to **Settings > Integrations > MCP** and create an API token.
|
||||
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
|
||||
3. Add it to your `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -55,7 +88,65 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
|
||||
}
|
||||
```
|
||||
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
|
||||
static token will receive deprecation warnings in the AI client via server instructions and tool results.
|
||||
|
||||
Each user can create up to **10 static tokens**.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
|
||||
|
||||
| Method | Token prefix | Access level | TTL | Notes |
|
||||
|--------|-------------|-------------|-----|-------|
|
||||
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
|
||||
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
|
||||
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
|
||||
|
||||
All methods require the `Authorization: Bearer <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. |
|
||||
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
|
||||
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
|
||||
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. |
|
||||
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
|
||||
| **Token limits** | Maximum 10 API tokens per user. |
|
||||
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
|
||||
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
|
||||
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
|
||||
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
|
||||
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
|
||||
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
|
||||
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
|
||||
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
|
||||
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. |
|
||||
|
||||
@@ -356,11 +450,12 @@ trip in a single call.
|
||||
|
||||
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
|
||||
|
||||
| Prompt | Description |
|
||||
|-------------------|---------------------------------------------------------------------------------|
|
||||
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
|
||||
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
|
||||
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
|
||||
| Prompt | Description |
|
||||
|----------------------|---------------------------------------------------------------------------------|
|
||||
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
|
||||
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
|
||||
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
|
||||
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<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="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>
|
||||
@@ -77,7 +77,8 @@
|
||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
||||
|
||||
### AI / MCP Integration
|
||||
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
|
||||
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
|
||||
- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access
|
||||
- **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation
|
||||
- **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context
|
||||
- **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
|
||||
@@ -97,11 +98,23 @@
|
||||
- **PWA**: vite-plugin-pwa + Workbox
|
||||
- **Real-Time**: WebSocket (`ws`)
|
||||
- **State**: Zustand
|
||||
- **Auth**: JWT + OIDC + TOTP (MFA)
|
||||
- **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
|
||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||
- **Weather**: Open-Meteo API (free, no key required)
|
||||
- **Icons**: lucide-react
|
||||
|
||||
## Helm (Kubernetes)
|
||||
|
||||
A hosted Helm repository is available:
|
||||
|
||||
```sh
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](charts/README.md) for configuration options.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -148,17 +161,18 @@ services:
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
|
||||
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
|
||||
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
|
||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
||||
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
|
||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
||||
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
||||
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only)
|
||||
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
|
||||
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
||||
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
||||
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
|
||||
@@ -166,8 +180,8 @@ services:
|
||||
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
|
||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
||||
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
|
||||
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
|
||||
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/uploads
|
||||
@@ -180,7 +194,13 @@ services:
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
This example is aimed at reverse-proxy deployments. If you access TREK directly on `http://<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
|
||||
docker compose up -d
|
||||
@@ -253,6 +273,9 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400;
|
||||
# File uploads are capped at 50 MB; backup restore ZIPs can include the full
|
||||
# uploads directory and may exceed that — raise this value if restores fail.
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
|
||||
location / {
|
||||
@@ -290,10 +313,11 @@ trek.yourdomain.com {
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy. If you access TREK directly on `http://host:3000`, keep this `false`. | `false` |
|
||||
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For`. Use this only when TREK is actually behind a reverse proxy. | `1` |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
@@ -301,7 +325,7 @@ trek.yourdomain.com {
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
|
||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
|
||||
@@ -311,8 +335,8 @@ trek.yourdomain.com {
|
||||
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
## Optional API Keys
|
||||
|
||||
|
||||
@@ -10,8 +10,20 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
A hosted Helm repository is available:
|
||||
|
||||
```sh
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Or install directly from the local chart:
|
||||
|
||||
```sh
|
||||
helm install trek ./chart \
|
||||
--set ingress.enabled=true \
|
||||
@@ -32,5 +44,7 @@ See `values.yaml` for more options.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 0.1.0
|
||||
version: 2.9.13
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "latest"
|
||||
appVersion: "2.9.13"
|
||||
@@ -27,7 +27,7 @@ spec:
|
||||
fsGroup: 1000
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
image:
|
||||
repository: mauriceboe/trek
|
||||
tag: latest
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
@@ -19,17 +19,21 @@ env:
|
||||
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||
# LOG_LEVEL: "info"
|
||||
# "info" = concise user actions, "debug" = verbose details.
|
||||
# DEFAULT_LANGUAGE: "en"
|
||||
# Default language on the login page for users with no saved preference.
|
||||
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
|
||||
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
# ALLOWED_ORIGINS: ""
|
||||
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||
# APP_URL: "https://trek.example.com"
|
||||
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
|
||||
# Also used as the base URL for links in email notifications and other external links.
|
||||
# FORCE_HTTPS: "false"
|
||||
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
|
||||
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
|
||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||
# TRUST_PROXY: "1"
|
||||
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
|
||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||
@@ -40,7 +44,9 @@ env:
|
||||
# OIDC_DISPLAY_NAME: "SSO"
|
||||
# Label shown on the SSO login button.
|
||||
# OIDC_ONLY: "false"
|
||||
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
|
||||
# Set to "true" to force SSO-only mode: disables password login and password registration.
|
||||
# Overrides the granular toggles in Admin > Settings and cannot be changed at runtime.
|
||||
# First SSO login becomes admin on a fresh instance.
|
||||
# OIDC_ADMIN_CLAIM: ""
|
||||
# OIDC claim used to identify admin users.
|
||||
# OIDC_ADMIN_VALUE: ""
|
||||
@@ -51,10 +57,10 @@ env:
|
||||
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
|
||||
# DEMO_MODE: "false"
|
||||
# Enable demo mode (hourly data resets).
|
||||
# MCP_RATE_LIMIT: "60"
|
||||
# Max MCP API requests per user per minute. Defaults to 60.
|
||||
# MCP_MAX_SESSION_PER_USER: "5"
|
||||
# Max concurrent MCP sessions per user. Defaults to 5.
|
||||
# MCP_RATE_LIMIT: "300"
|
||||
# Max MCP API requests per user per minute. Defaults to 300.
|
||||
# MCP_MAX_SESSION_PER_USER: "20"
|
||||
# Max concurrent MCP sessions per user. Defaults to 20.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
|
||||
Generated
+1710
-1821
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "2.9.12",
|
||||
"version": "2.9.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -17,8 +17,10 @@
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.4.1",
|
||||
@@ -27,6 +29,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zustand": "^4.5.2"
|
||||
@@ -40,8 +43,9 @@
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.13.0",
|
||||
"postcss": "^8.4.35",
|
||||
@@ -50,6 +54,6 @@
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^4.1.2"
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
+52
-5
@@ -10,13 +10,20 @@ import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import JourneyPage from './pages/JourneyPage'
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||
import JourneyPublicPage from './pages/JourneyPublicPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
@@ -60,7 +67,12 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
|
||||
return <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() {
|
||||
@@ -78,16 +90,26 @@ function RootRedirect() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) {
|
||||
loadUser()
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
// If the persist snapshot already has an authenticated user, validate
|
||||
// silently so the PWA shell renders immediately without a spinner.
|
||||
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
|
||||
if (alreadyAuthenticated) {
|
||||
useAuthStore.setState({ isLoading: false })
|
||||
loadUser({ silent: true })
|
||||
} else {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<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?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
if (config?.version) setAppVersion(config.version)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
@@ -126,6 +148,11 @@ export default function App() {
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
registerSyncTriggers()
|
||||
return () => unregisterSyncTriggers()
|
||||
}, [])
|
||||
|
||||
const location = useLocation()
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
@@ -158,11 +185,15 @@ export default function App() {
|
||||
return (
|
||||
<TranslationProvider>
|
||||
<ToastContainer />
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
@@ -219,6 +250,22 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<JourneyPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<JourneyDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
|
||||
+154
-4
@@ -1,7 +1,36 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
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',
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
@@ -9,24 +38,36 @@ const apiClient: AxiosInstance = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add socket ID
|
||||
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
||||
|
||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
// Attach a per-request idempotency key to all write operations so the
|
||||
// server can deduplicate retried requests (e.g. network blips).
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor - handle 401
|
||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
|
||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
|
||||
const currentPath = window.location.pathname + window.location.search
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
}
|
||||
@@ -38,6 +79,16 @@ apiClient.interceptors.response.use(
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
}
|
||||
error.message = translated
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -72,6 +123,43 @@ export const authApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export const oauthApi = {
|
||||
/** Validate OAuth authorize params — called by consent page on load */
|
||||
validate: (params: {
|
||||
response_type: string
|
||||
client_id: string
|
||||
redirect_uri: string
|
||||
scope: string
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
|
||||
|
||||
/** Submit user consent (approve or deny) */
|
||||
authorize: (body: {
|
||||
client_id: string
|
||||
redirect_uri: string
|
||||
scope: string
|
||||
state?: string
|
||||
code_challenge: string
|
||||
code_challenge_method: string
|
||||
approved: boolean
|
||||
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
|
||||
|
||||
clients: {
|
||||
list: () => apiClient.get('/oauth/clients').then(r => r.data),
|
||||
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
|
||||
apiClient.post('/oauth/clients', data).then(r => r.data),
|
||||
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
|
||||
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
|
||||
},
|
||||
|
||||
sessions: {
|
||||
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
|
||||
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
|
||||
},
|
||||
}
|
||||
|
||||
export const tripsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).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),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
|
||||
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
@@ -105,8 +194,14 @@ export const placesApi = {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importMapFile: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
@@ -195,6 +290,8 @@ export const adminApi = {
|
||||
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
|
||||
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
|
||||
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
|
||||
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
|
||||
revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data),
|
||||
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
|
||||
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
|
||||
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
|
||||
@@ -208,8 +305,56 @@ export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const journeyApi = {
|
||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
|
||||
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
|
||||
update: (id: number, data: Record<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 = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||
@@ -258,6 +403,11 @@ export const weatherApi = {
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const configApi = {
|
||||
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
|
||||
apiClient.get('/config').then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,6 +13,8 @@ let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
let connecting = false
|
||||
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
|
||||
let preReconnectHook: (() => Promise<void>) | null = null
|
||||
|
||||
export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
@@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a hook that runs (and is awaited) before the refetch callback
|
||||
* fires on WS reconnect. Use this to flush the mutation queue so queued
|
||||
* local writes reach the server before the app reads back canonical state.
|
||||
* Pass null to clear.
|
||||
*/
|
||||
export function setPreReconnectHook(fn: (() => Promise<void>) | null): void {
|
||||
preReconnectHook = fn
|
||||
}
|
||||
|
||||
function getWsUrl(wsToken: string): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${wsToken}`
|
||||
@@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
}
|
||||
})
|
||||
if (refetchCallback) {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
const doRefetch = () => {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Flush queued mutations first so local writes land before server read-back.
|
||||
// If the hook fails, still refetch to keep the UI correct.
|
||||
if (preReconnectHook) {
|
||||
preReconnectHook().catch(console.error).then(doRefetch)
|
||||
} else {
|
||||
doRefetch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,11 +190,12 @@ describe('AddonManager', () => {
|
||||
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown for Memories addon', async () => {
|
||||
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () =>
|
||||
HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
|
||||
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
|
||||
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
|
||||
@@ -204,18 +205,16 @@ describe('AddonManager', () => {
|
||||
);
|
||||
render(<AddonManager />);
|
||||
|
||||
// Provider sub-rows are visible
|
||||
// Provider sub-rows are visible under Journey addon
|
||||
await screen.findByText('Unsplash');
|
||||
expect(screen.getByText('Pexels')).toBeInTheDocument();
|
||||
|
||||
// Memories row shows name override
|
||||
expect(screen.getByText('Memories providers')).toBeInTheDocument();
|
||||
// Journey addon is rendered
|
||||
expect(screen.getByText('Journey')).toBeInTheDocument();
|
||||
|
||||
// The photos addon row itself has no top-level toggle (hideToggle = true)
|
||||
// The toggle buttons are only for the providers
|
||||
// Toggle buttons: journey toggle + 2 provider toggles
|
||||
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
// Should be 2 provider toggles (no main toggle for the photos addon)
|
||||
expect(toggleBtns.length).toBe(2);
|
||||
expect(toggleBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
|
||||
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -103,11 +103,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
}
|
||||
}
|
||||
|
||||
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
||||
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
const photosAddon = tripAddons.find(isPhotosAddon)
|
||||
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
@@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow
|
||||
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>
|
||||
)}
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{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 style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
|
||||
</span>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-010
|
||||
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -197,4 +197,127 @@ describe('AdminMcpTokensPanel', () => {
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
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 { adminApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Key, Trash2, User, Loader2 } from 'lucide-react'
|
||||
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface AdminOAuthSession {
|
||||
id: number
|
||||
client_id: string
|
||||
client_name: string
|
||||
user_id: number
|
||||
username: string
|
||||
scopes: string[]
|
||||
access_token_expires_at: string
|
||||
refresh_token_expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface AdminMcpToken {
|
||||
id: number
|
||||
name: string
|
||||
@@ -14,21 +26,49 @@ interface AdminMcpToken {
|
||||
username: string
|
||||
}
|
||||
|
||||
const SCOPES_PREVIEW = 6
|
||||
|
||||
export default function AdminMcpTokensPanel() {
|
||||
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
|
||||
const [sessionsLoading, setSessionsLoading] = useState(true)
|
||||
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 toggleScopes = (id: number) =>
|
||||
setExpandedScopes(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(id) ? next.delete(id) : next.add(id)
|
||||
return next
|
||||
})
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
adminApi.oauthSessions()
|
||||
.then(d => setSessions(d.sessions || []))
|
||||
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
|
||||
.finally(() => setSessionsLoading(false))
|
||||
|
||||
adminApi.mcpTokens()
|
||||
.then(d => setTokens(d.tokens || []))
|
||||
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
|
||||
.finally(() => setIsLoading(false))
|
||||
.finally(() => setTokensLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await adminApi.revokeOAuthSession(id)
|
||||
setSessions(prev => prev.filter(s => s.id !== id))
|
||||
setRevokeConfirmId(null)
|
||||
toast.success(t('admin.oauthSessions.revokeSuccess'))
|
||||
} catch {
|
||||
toast.error(t('admin.oauthSessions.revokeError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await adminApi.deleteMcpToken(id)
|
||||
@@ -47,55 +87,156 @@ export default function AdminMcpTokensPanel() {
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{isLoading ? (
|
||||
<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>
|
||||
{/* OAuth Sessions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
|
||||
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||
{sessionsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</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>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</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"
|
||||
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
|
||||
<span>{t('admin.oauthSessions.clientName')}</span>
|
||||
<span>{t('admin.oauthSessions.owner')}</span>
|
||||
<span className="text-right">{t('admin.oauthSessions.created')}</span>
|
||||
<span></span>
|
||||
</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>
|
||||
|
||||
{/* 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 && (
|
||||
<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) }}>
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('GitHubPanel', () => {
|
||||
server.use(
|
||||
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
|
||||
);
|
||||
render(<GitHubPanel />);
|
||||
render(<GitHubPanel isPrerelease={true} />);
|
||||
await screen.findByText('v3.0.0-beta.1');
|
||||
expect(screen.getByText('Pre-release')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -6,12 +6,18 @@ import apiClient from '../../api/client'
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
export default function GitHubPanel() {
|
||||
interface GithubRelease {
|
||||
id: number
|
||||
prerelease: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState([])
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
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="space-y-0">
|
||||
{releases.map((release, idx) => {
|
||||
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
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 userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
|
||||
import BudgetPanel from './BudgetPanel';
|
||||
@@ -418,4 +419,80 @@ describe('BudgetPanel', () => {
|
||||
// Grand total card shows 300.00
|
||||
expect(screen.getByText('300.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
|
||||
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
|
||||
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
|
||||
// Use a user with id != 1 so they're not the owner
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
|
||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
|
||||
);
|
||||
render(<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -956,15 +956,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{pieSegments.map(seg => {
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{pieSegments.map((seg, i) => {
|
||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||
return (
|
||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
|
||||
<div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -370,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
return () => { deleteTimersRef.current.forEach(clearTimeout) }
|
||||
}, [])
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const messagesRef = useRef(messages)
|
||||
@@ -483,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
setTimeout(async () => {
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||
} catch {}
|
||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||
}, 400)
|
||||
deleteTimersRef.current.push(t)
|
||||
}, [tripId])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
@@ -762,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<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 */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
@@ -110,10 +112,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
const isTxt = file.mime_type?.startsWith('text/')
|
||||
|
||||
const openInNewTab = async () => {
|
||||
const u = await getAuthUrl(rawUrl, 'download')
|
||||
window.open(u, '_blank', 'noreferrer')
|
||||
}
|
||||
const openInNewTab = () => openFile(rawUrl).catch(() => {})
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<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',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1352,7 +1351,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
||||
</div>
|
||||
</div>
|
||||
<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 && (
|
||||
<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>
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
disconnect: vi.fn(),
|
||||
getSocketId: vi.fn(() => null),
|
||||
setRefetchCallback: vi.fn(),
|
||||
setPreReconnectHook: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -16,12 +16,13 @@ function formatTime(timeStr, is12h) {
|
||||
}
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const d = new Date(date + 'T00:00:00')
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
|
||||
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
|
||||
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { downloadFile, openFile } from '../../utils/fileDownload'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
@@ -30,16 +31,8 @@ function formatSize(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
async function triggerDownload(url: string, filename: string) {
|
||||
const authUrl = await getAuthUrl(url, 'download')
|
||||
const res = await fetch(authUrl)
|
||||
const blob = await res.blob()
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove() }, 100)
|
||||
function triggerDownload(url: string, filename: string) {
|
||||
downloadFile(url, filename).catch(() => {})
|
||||
}
|
||||
|
||||
function formatDateWithLocale(dateStr, locale) {
|
||||
@@ -120,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
|
||||
<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 }}
|
||||
title={t('files.openTab')}>
|
||||
<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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<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' }}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
|
||||
@@ -778,7 +771,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<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>
|
||||
</object>
|
||||
</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: '© <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('')).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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ beforeEach(() => {
|
||||
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true });
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
|
||||
seedStore(useSettingsStore, { settings: buildSettings() });
|
||||
});
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
|
||||
|
||||
interface NavbarProps {
|
||||
tripTitle?: string
|
||||
@@ -27,14 +27,13 @@ interface Addon {
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
@@ -45,12 +44,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
if (user) loadAddons()
|
||||
}, [user, location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
import('../../api/client').then(({ authApi }) => {
|
||||
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
@@ -75,7 +68,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
touchAction: 'manipulation',
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
height: 'var(--nav-h)',
|
||||
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{showBack && (
|
||||
@@ -155,6 +148,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
</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 */}
|
||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
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', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
|
||||
{ asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
|
||||
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
|
||||
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
|
||||
],
|
||||
})
|
||||
),
|
||||
@@ -501,8 +501,8 @@ describe('MemoriesPanel', () => {
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
@@ -676,8 +676,8 @@ describe('MemoriesPanel', () => {
|
||||
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
|
||||
HttpResponse.json({
|
||||
photos: [
|
||||
{ asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
|
||||
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
|
||||
],
|
||||
})
|
||||
),
|
||||
|
||||
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TripPhoto {
|
||||
photo_id: number
|
||||
asset_id: string
|
||||
provider: string
|
||||
user_id: number
|
||||
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
}
|
||||
|
||||
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
|
||||
return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
|
||||
return `/photos/${photo.photo_id}/${what}`
|
||||
}
|
||||
|
||||
function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
|
||||
const photo: TripPhoto = {
|
||||
asset_id: asset.id,
|
||||
provider: asset.provider,
|
||||
user_id: userId,
|
||||
username: '',
|
||||
shared: 0,
|
||||
added_at: null
|
||||
}
|
||||
return buildProviderAssetUrl(photo, what)
|
||||
// Picker photos are not yet saved — use provider-specific URL
|
||||
return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
}
|
||||
|
||||
// Lightbox
|
||||
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||
const [lightboxId, setLightboxId] = useState<number | null>(null)
|
||||
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
try {
|
||||
await apiClient.delete(buildUnifiedUrl('photos'), {
|
||||
data: {
|
||||
asset_id: photo.asset_id,
|
||||
provider: photo.provider,
|
||||
photo_id: photo.photo_id,
|
||||
},
|
||||
})
|
||||
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
|
||||
setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id))
|
||||
} catch { toast.error(t('memories.error.removePhoto')) }
|
||||
}
|
||||
|
||||
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
try {
|
||||
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
|
||||
shared,
|
||||
asset_id: photo.asset_id,
|
||||
provider: photo.provider,
|
||||
photo_id: photo.photo_id,
|
||||
})
|
||||
setTripPhotos(prev => prev.map(p =>
|
||||
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p
|
||||
))
|
||||
} catch { toast.error(t('memories.error.toggleSharing')) }
|
||||
}
|
||||
@@ -714,6 +706,23 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
return (
|
||||
<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 */}
|
||||
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
@@ -822,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
{allVisible.map(photo => {
|
||||
const isOwn = photo.user_id === currentUser?.id
|
||||
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' }}
|
||||
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)
|
||||
setLightboxOriginalSrc('')
|
||||
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
|
||||
@@ -944,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
setShowMobileInfo(false)
|
||||
}
|
||||
|
||||
const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
|
||||
const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId)
|
||||
const hasPrev = currentIdx > 0
|
||||
const hasNext = currentIdx < allVisible.length - 1
|
||||
const navigateTo = (idx: number) => {
|
||||
@@ -952,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
if (!photo) return
|
||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||
setLightboxOriginalSrc('')
|
||||
setLightboxId(photo.asset_id)
|
||||
setLightboxId(photo.photo_id)
|
||||
setLightboxUserId(photo.user_id)
|
||||
setLightboxInfo(null)
|
||||
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-010
|
||||
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -99,4 +99,109 @@ describe('InAppNotificationItem', () => {
|
||||
// Recent notification shows "just now"
|
||||
expect(screen.getByText('just now')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
|
||||
render(
|
||||
<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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function md(str: string | null | undefined): string {
|
||||
if (!str) return ''
|
||||
return marked.parse(str, { async: false, breaks: true }) as string
|
||||
}
|
||||
|
||||
function abs(url: string | null | undefined): string {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
|
||||
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
|
||||
}
|
||||
|
||||
function pSrc(p: JourneyPhoto): string {
|
||||
return abs(`/api/photos/${p.photo_id}/original`)
|
||||
}
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtShort(d: string): string {
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function groupByDate(entries: JourneyEntry[]): Map<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()
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Trip PDF via browser print window
|
||||
import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
|
||||
import { accommodationsApi, mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
|
||||
@@ -18,10 +18,12 @@ function noteIconSvg(iconId) {
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
|
||||
}
|
||||
|
||||
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||
function transportIconSvg(type) {
|
||||
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })
|
||||
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
|
||||
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
function reservationIconSvg(type) {
|
||||
const Icon = RESERVATION_ICON_MAP[type] || Ticket
|
||||
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
|
||||
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color })
|
||||
}
|
||||
|
||||
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
|
||||
@@ -144,19 +146,18 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||
const cost = dayCost(assignments, day.id, loc)
|
||||
|
||||
// Transport bookings for this day
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const dayTransport = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||
// Reservations for this day (hotel rendered via accommodations block)
|
||||
const dayReservations = (reservations || []).filter(r => {
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||
})
|
||||
|
||||
const merged = []
|
||||
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||
dayTransport.forEach(r => {
|
||||
dayReservations.forEach(r => {
|
||||
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||
merged.push({ type: 'transport', k: pos, data: r })
|
||||
merged.push({ type: 'reservation', k: pos, data: r })
|
||||
})
|
||||
merged.sort((a, b) => a.k - b.k)
|
||||
|
||||
@@ -164,21 +165,27 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const itemsHtml = merged.length === 0
|
||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||
: merged.map(item => {
|
||||
if (item.type === 'transport') {
|
||||
if (item.type === 'reservation') {
|
||||
const r = item.data
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const icon = transportIconSvg(r.type)
|
||||
const icon = reservationIconSvg(r.type)
|
||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||
let subtitle = ''
|
||||
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
const locationLine = r.location || meta.location || ''
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
return `
|
||||
<div class="note-card" style="border-left: 3px solid #3b82f6;">
|
||||
<div class="note-line" style="background: #3b82f6;"></div>
|
||||
<div class="note-card" style="border-left: 3px solid ${color};">
|
||||
<div class="note-line" style="background: ${color};"></div>
|
||||
<span class="note-icon">${icon}</span>
|
||||
<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>
|
||||
${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>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
|
||||
@@ -467,6 +467,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
const [showAddItem, setShowAddItem] = useState(false)
|
||||
const [newItemName, setNewItemName] = useState('')
|
||||
const addItemRef = useRef<HTMLInputElement>(null)
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { togglePackingItem } = useTripStore()
|
||||
const toast = useToast()
|
||||
@@ -629,22 +630,27 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</span>
|
||||
|
||||
<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)'}>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
{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 }}
|
||||
onMouseLeave={() => setShowMenu(false)}>
|
||||
{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) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
{canEdit && <>
|
||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
{showMenu && (() => {
|
||||
const rect = menuBtnRef.current?.getBoundingClientRect();
|
||||
return (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => 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 && <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) }} />
|
||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
||||
{canEdit && <>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
value={caption}
|
||||
onChange={e => setCaption(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()}
|
||||
placeholder="Beschriftung hinzufügen..."
|
||||
placeholder={t('photos.addCaption')}
|
||||
className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -173,7 +173,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
|
||||
onClick={() => setEditCaption(true)}
|
||||
>
|
||||
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>}
|
||||
{photo.caption || <span className="text-white/40 italic">{t('photos.addCaption')}</span>}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setEditCaption(true)}
|
||||
|
||||
@@ -43,15 +43,15 @@ describe('PhotoUpload', () => {
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => {
|
||||
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
|
||||
expect(document.querySelector('svg')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
expect(screen.queryByText('Tag verknüpfen')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('Optionale Beschriftung...')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Link Day')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('Optional caption...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => {
|
||||
@@ -65,27 +65,27 @@ describe('PhotoUpload', () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
await uploadFiles([makeFile()])
|
||||
expect(screen.getByAltText('photo.jpg')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tag verknüpfen')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Optionale Beschriftung...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Link Day')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Optional caption...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
|
||||
expect(screen.getByText('2 Fotos ausgewählt')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PHOTOUPLOAD-006: remove button removes a file from preview', async () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
|
||||
expect(screen.getByText('2 Fotos ausgewählt')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
|
||||
|
||||
// Remove buttons are inside `.relative.aspect-square` wrappers in the preview grid
|
||||
const removeButtons = document.querySelectorAll('.relative.aspect-square button')
|
||||
expect(removeButtons.length).toBe(2)
|
||||
await userEvent.click(removeButtons[0])
|
||||
|
||||
expect(screen.getByText('1 Foto ausgewählt')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 Photo selected')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('img').length).toBe(1)
|
||||
})
|
||||
|
||||
@@ -120,7 +120,7 @@ describe('PhotoUpload', () => {
|
||||
render(<PhotoUpload {...defaultProps} />)
|
||||
await uploadFiles([makeFile()])
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('Optionale Beschriftung...'), 'Vacation')
|
||||
await userEvent.type(screen.getByPlaceholderText('Optional caption...'), 'Vacation')
|
||||
|
||||
await userEvent.click(getSubmitButton())
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('PhotoUpload', () => {
|
||||
await userEvent.click(getSubmitButton())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/wird hochgeladen/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/uploading/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
expect(getSubmitButton()).toBeDisabled()
|
||||
|
||||
@@ -85,12 +85,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
<input {...getInputProps()} />
|
||||
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
|
||||
{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-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>
|
||||
@@ -98,7 +98,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
{/* Preview grid */}
|
||||
{files.length > 0 && (
|
||||
<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">
|
||||
{files.map((file, idx) => (
|
||||
<div key={idx} className="relative aspect-square group">
|
||||
@@ -126,15 +126,15 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
{files.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
value={dayId}
|
||||
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"
|
||||
>
|
||||
<option value="">Kein Tag</option>
|
||||
<option value="">{t('photos.noDay')}</option>
|
||||
{(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>
|
||||
</div>
|
||||
@@ -152,12 +152,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
|
||||
</select>
|
||||
</div>
|
||||
<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
|
||||
type="text"
|
||||
value={caption}
|
||||
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"
|
||||
/>
|
||||
</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="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" />
|
||||
<span className="text-sm text-slate-900">Wird hochgeladen...</span>
|
||||
<span className="text-sm text-slate-900">{t('common.uploading')}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1.5">
|
||||
<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" }
|
||||
|
||||
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={{
|
||||
background: 'var(--bg-elevated)',
|
||||
backdropFilter: 'blur(40px) saturate(180%)',
|
||||
@@ -189,7 +189,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</div>
|
||||
{!collapsed && formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</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' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
|
||||
|
||||
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||
@@ -55,6 +55,99 @@ const TYPE_ICONS = {
|
||||
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
|
||||
}
|
||||
|
||||
function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onAddNew }: {
|
||||
dayId: number
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
onAssign?: (placeId: number, dayId: number) => void
|
||||
onAddNew?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// Find places not assigned to this day
|
||||
const assignedToDay = new Set((assignments[String(dayId)] || []).map(a => a.place_id))
|
||||
const available = places.filter(p => !assignedToDay.has(p.id))
|
||||
const filtered = search.trim()
|
||||
? available.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: available
|
||||
|
||||
return (
|
||||
<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 {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
@@ -79,6 +172,8 @@ interface DayPlanSidebarProps {
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
onNavigateToFiles?: () => void
|
||||
onAddPlace?: () => void
|
||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
canUndo?: boolean
|
||||
@@ -95,6 +190,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onNavigateToFiles,
|
||||
onExpandedDaysChange,
|
||||
pushUndo,
|
||||
@@ -519,7 +616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
@@ -606,7 +703,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
tripActions.setAssignments(currentAssignments)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -755,9 +852,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId && fromDayId !== dayId) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
@@ -862,7 +959,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
a.download = `${trip?.title || 'trip'}.ics`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { toast.error('ICS export failed') }
|
||||
} catch { toast.error(t('planner.icsExportFailed')) }
|
||||
}}
|
||||
onMouseEnter={() => setIcsHover(true)}
|
||||
onMouseLeave={() => setIcsHover(false)}
|
||||
@@ -1089,11 +1186,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (assignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
|
||||
}
|
||||
@@ -1107,11 +1204,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -1207,7 +1304,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
@@ -1215,7 +1312,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
@@ -1227,7 +1324,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
@@ -1411,11 +1508,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
|
||||
}
|
||||
@@ -1499,7 +1596,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
@@ -1507,7 +1604,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
@@ -1572,11 +1669,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -1623,6 +1720,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: Add Place from list */}
|
||||
<MobileAddPlaceButton
|
||||
dayId={day.id}
|
||||
places={places}
|
||||
assignments={assignments}
|
||||
onAssign={onAssignToDay}
|
||||
onAddNew={onAddPlace}
|
||||
/>
|
||||
</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
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -6,7 +6,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||
import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import type { Place, Category, Assignment } from '../../types'
|
||||
@@ -25,6 +25,25 @@ interface PlaceFormData {
|
||||
website: string
|
||||
}
|
||||
|
||||
function isGoogleMapsUrl(input: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(input.trim())
|
||||
const h = hostname.toLowerCase()
|
||||
// maps.app.goo.gl, goo.gl/maps
|
||||
if (h === 'maps.app.goo.gl') return true
|
||||
if (h === 'goo.gl' && pathname.startsWith('/maps')) return true
|
||||
// maps.google.* (e.g. maps.google.com, maps.google.co.uk)
|
||||
// Must be maps.google.<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 = {
|
||||
name: '',
|
||||
description: '',
|
||||
@@ -65,6 +84,10 @@ export default function PlaceFormModal({
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||
const [acHighlight, setAcHighlight] = useState(-1)
|
||||
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const acAbortRef = useRef<AbortController | null>(null)
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { hasMapsKey } = useAuthStore()
|
||||
@@ -101,6 +124,73 @@ export default function PlaceFormModal({
|
||||
setPendingFiles([])
|
||||
}, [place, prefillCoords, isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
const places = useTripStore((s) => s.places)
|
||||
const locationBias = useMemo(() => {
|
||||
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
|
||||
if (withCoords.length === 0) return undefined
|
||||
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
for (const p of withCoords) {
|
||||
const lat = Number(p.lat), lng = Number(p.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
if (lat < minLat) minLat = lat
|
||||
if (lat > maxLat) maxLat = lat
|
||||
if (lng < minLng) minLng = lng
|
||||
if (lng > maxLng) maxLng = lng
|
||||
}
|
||||
if (!Number.isFinite(minLat)) return undefined
|
||||
|
||||
// Skip bias if the bounding box is too large (~500 km diagonal)
|
||||
const dlat = maxLat - minLat
|
||||
const dlng = maxLng - minLng
|
||||
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
|
||||
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
|
||||
if (diagKm > 500) return undefined
|
||||
|
||||
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
||||
}, [places])
|
||||
|
||||
// Autocomplete fetch — aborts any in-flight request before starting a new one
|
||||
const fetchSuggestions = useCallback(async (query: string) => {
|
||||
if (query.length < 2 || isGoogleMapsUrl(query)) {
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
return
|
||||
}
|
||||
acAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
acAbortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal)
|
||||
setAcSuggestions(result.suggestions || [])
|
||||
setAcHighlight(-1)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
if (err instanceof Error && err.name === 'CanceledError') return // axios abort
|
||||
console.error('Autocomplete failed:', err)
|
||||
setAcSuggestions([])
|
||||
}
|
||||
}, [language, locationBias])
|
||||
|
||||
// Debounce effect — only watches mapsSearch
|
||||
useEffect(() => {
|
||||
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
|
||||
|
||||
const trimmed = mapsSearch.trim()
|
||||
if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) {
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
return
|
||||
}
|
||||
|
||||
acDebounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300)
|
||||
|
||||
return () => {
|
||||
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
|
||||
}
|
||||
}, [mapsSearch, fetchSuggestions])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
@@ -111,7 +201,7 @@ export default function PlaceFormModal({
|
||||
try {
|
||||
// Detect Google Maps URLs and resolve them directly
|
||||
const trimmed = mapsSearch.trim()
|
||||
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
|
||||
if (isGoogleMapsUrl(trimmed)) {
|
||||
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||
if (resolved.lat && resolved.lng) {
|
||||
setForm(prev => ({
|
||||
@@ -152,6 +242,56 @@ export default function PlaceFormModal({
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
setMapsSearch('')
|
||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place) {
|
||||
handleSelectMapsResult(result.place)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
} finally {
|
||||
setIsSearchingMaps(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (acSuggestions.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setAcHighlight(prev => (prev + 1) % acSuggestions.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setAcHighlight(prev => (prev <= 0 ? acSuggestions.length - 1 : prev - 1))
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (acHighlight >= 0) {
|
||||
handleSelectSuggestion(acSuggestions[acHighlight])
|
||||
} else {
|
||||
setAcSuggestions([])
|
||||
handleMapsSearch()
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleMapsSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateCategory = async () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
try {
|
||||
@@ -229,24 +369,56 @@ export default function PlaceFormModal({
|
||||
{t('places.osmActive')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={mapsSearch}
|
||||
onChange={e => setMapsSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())}
|
||||
placeholder={t('places.mapsSearchPlaceholder')}
|
||||
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={mapsSearch}
|
||||
onChange={e => setMapsSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
onBlur={() => setTimeout(() => setAcSuggestions([]), 150)}
|
||||
onFocus={() => {
|
||||
if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) {
|
||||
fetchSuggestions(mapsSearch.trim())
|
||||
}
|
||||
}}
|
||||
placeholder={t('places.mapsSearchPlaceholder')}
|
||||
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Search results (populated after full search) */}
|
||||
{mapsResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
|
||||
{mapsResults.map((result, idx) => (
|
||||
@@ -267,14 +439,21 @@ export default function PlaceFormModal({
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => handleChange('name', e.target.value)}
|
||||
required
|
||||
placeholder={t('places.formNamePlaceholder')}
|
||||
className="form-input"
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => handleChange('name', e.target.value)}
|
||||
required
|
||||
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>
|
||||
|
||||
{/* Description */}
|
||||
@@ -285,7 +464,20 @@ export default function PlaceFormModal({
|
||||
onChange={e => handleChange('description', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('places.formDescriptionPlaceholder')}
|
||||
className="form-input" style={{ resize: 'none' }}
|
||||
className="form-input" style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||
@@ -341,9 +341,16 @@ export default function PlaceInspector({
|
||||
)}
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || place.notes || googleDetails?.summary) && (
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -582,7 +589,7 @@ export default function PlaceInspector({
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{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" />}
|
||||
<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>}
|
||||
@@ -601,7 +608,7 @@ export default function PlaceInspector({
|
||||
{selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<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')} />
|
||||
)
|
||||
@@ -611,7 +618,7 @@ export default function PlaceInspector({
|
||||
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
||||
)}
|
||||
{!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>} />
|
||||
)}
|
||||
{(place.website || googleDetails?.website) && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { placesApi } from '../../api/client';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -432,32 +433,29 @@ describe('Mobile day-picker (portal)', () => {
|
||||
// ── GPX import ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GPX import', () => {
|
||||
it('FE-PLANNER-SIDEBAR-038: GPX import button triggers file input click', async () => {
|
||||
it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlacesSidebar {...defaultProps} />);
|
||||
const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
|
||||
expect(fileInput).toBeTruthy();
|
||||
const clickSpy = vi.spyOn(fileInput, 'click');
|
||||
await user.click(screen.getByText(/GPX/i));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
await user.click(screen.getByText(/Import file/i));
|
||||
expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/places/import/gpx', () =>
|
||||
HttpResponse.json({ count: 2, places: [{ id: 10 }, { id: 11 }] })
|
||||
),
|
||||
);
|
||||
it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => {
|
||||
const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
|
||||
const loadTrip = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useTripStore, { loadTrip });
|
||||
const addToast = vi.fn();
|
||||
(window as any).__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
render(<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' });
|
||||
await act(async () => {
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /^import$/i }));
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('2'),
|
||||
@@ -465,6 +463,7 @@ describe('GPX import', () => {
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
importSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import FileImportModal from './FileImportModal'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -28,7 +28,7 @@ interface PlacesSidebarProps {
|
||||
onDeletePlace: (placeId: number) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryId: string) => void
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
onPlacesFilterChange?: (filter: string) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
@@ -40,50 +40,77 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||
const can = useCanDo()
|
||||
const canEditPlaces = can('place_edit', trip)
|
||||
const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import'))
|
||||
|
||||
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
try {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('places.gpxImported', { count: result.count }))
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGpx'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||
}
|
||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
||||
const [sidebarDragOver, setSidebarDragOver] = useState(false)
|
||||
const sidebarDragCounter = useRef(0)
|
||||
|
||||
const handleSidebarDragEnter = (e: React.DragEvent) => {
|
||||
if (!canEditPlaces) return
|
||||
e.preventDefault()
|
||||
sidebarDragCounter.current++
|
||||
setSidebarDragOver(true)
|
||||
}
|
||||
|
||||
const [googleListOpen, setGoogleListOpen] = useState(false)
|
||||
const [googleListUrl, setGoogleListUrl] = useState('')
|
||||
const [googleListLoading, setGoogleListLoading] = useState(false)
|
||||
const handleSidebarDragOver = (e: React.DragEvent) => {
|
||||
if (!canEditPlaces) return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleGoogleListImport = async () => {
|
||||
if (!googleListUrl.trim()) return
|
||||
setGoogleListLoading(true)
|
||||
const handleSidebarDragLeave = () => {
|
||||
sidebarDragCounter.current--
|
||||
if (sidebarDragCounter.current === 0) setSidebarDragOver(false)
|
||||
}
|
||||
|
||||
const handleSidebarDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
sidebarDragCounter.current = 0
|
||||
setSidebarDragOver(false)
|
||||
if (!canEditPlaces) return
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (!f) return
|
||||
setSidebarDropFile(f)
|
||||
setFileImportOpen(true)
|
||||
}
|
||||
|
||||
const [listImportOpen, setListImportOpen] = useState(false)
|
||||
const [listImportUrl, setListImportUrl] = useState('')
|
||||
const [listImportLoading, setListImportLoading] = useState(false)
|
||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNaverListImportEnabled && listImportProvider === 'naver') {
|
||||
setListImportProvider('google')
|
||||
}
|
||||
}, [isNaverListImportEnabled, listImportProvider])
|
||||
|
||||
const handleListImport = async () => {
|
||||
if (!listImportUrl.trim()) return
|
||||
setListImportLoading(true)
|
||||
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
||||
try {
|
||||
const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
|
||||
const result = provider === 'google'
|
||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
||||
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
||||
await loadTrip(tripId)
|
||||
toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
|
||||
setGoogleListOpen(false)
|
||||
setGoogleListUrl('')
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
} else {
|
||||
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
|
||||
}
|
||||
setListImportOpen(false)
|
||||
setListImportUrl('')
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGoogleList'), async () => {
|
||||
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
@@ -91,9 +118,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t('places.googleListError'))
|
||||
toast.error(err?.response?.data?.error || t(provider === 'google' ? 'places.googleListError' : 'places.naverListError'))
|
||||
} finally {
|
||||
setGoogleListLoading(false)
|
||||
setListImportLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +132,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
setCategoryFiltersLocal(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
||||
// Notify parent with first selected or empty
|
||||
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
|
||||
onCategoryFilterChange?.(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -131,7 +157,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{canEditPlaces && <button
|
||||
@@ -146,10 +191,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||
</button>}
|
||||
{canEditPlaces && <>
|
||||
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||
<button
|
||||
onClick={() => gpxInputRef.current?.click()}
|
||||
onClick={() => setFileImportOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
@@ -158,10 +202,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setGoogleListOpen(true)}
|
||||
onClick={() => setListImportOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
@@ -170,7 +214,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')}
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
@@ -257,7 +301,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
)
|
||||
})}
|
||||
{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,
|
||||
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
|
||||
@@ -317,7 +361,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
@@ -381,7 +425,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
>
|
||||
<div
|
||||
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={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
|
||||
@@ -448,9 +492,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{googleListOpen && ReactDOM.createPortal(
|
||||
{listImportOpen && ReactDOM.createPortal(
|
||||
<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 }}
|
||||
>
|
||||
<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)' }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
|
||||
{t('places.importGoogleList')}
|
||||
{t('places.importList')}
|
||||
</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 }}>
|
||||
{t('places.googleListHint')}
|
||||
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={googleListUrl}
|
||||
onChange={e => setGoogleListUrl(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
|
||||
placeholder="https://maps.app.goo.gl/..."
|
||||
value={listImportUrl}
|
||||
onChange={e => setListImportUrl(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !listImportLoading) handleListImport() }}
|
||||
placeholder={listImportProvider === 'google' ? 'https://maps.app.goo.gl/...' : 'https://naver.me/...'}
|
||||
autoFocus
|
||||
style={{
|
||||
width: '100%', padding: '10px 14px', borderRadius: 10,
|
||||
@@ -479,7 +541,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
||||
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||
@@ -489,23 +551,30 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoogleListImport}
|
||||
disabled={!googleListUrl.trim() || googleListLoading}
|
||||
onClick={handleListImport}
|
||||
disabled={!listImportUrl.trim() || listImportLoading}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer',
|
||||
background: !listImportUrl.trim() || listImportLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||
color: !listImportUrl.trim() || listImportLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 500, cursor: !listImportUrl.trim() || listImportLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{googleListLoading ? t('common.loading') : t('common.import')}
|
||||
{listImportLoading ? t('common.loading') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<FileImportModal
|
||||
isOpen={fileImportOpen}
|
||||
onClose={() => { setFileImportOpen(false); setSidebarDropFile(null) }}
|
||||
tripId={tripId}
|
||||
pushUndo={pushUndo}
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -587,7 +587,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<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 }} />
|
||||
<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 () => {
|
||||
// Always unlink, never delete the file
|
||||
// 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,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||
} from 'lucide-react'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
@@ -253,7 +253,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<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 }}>
|
||||
{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 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
@@ -63,7 +63,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -94,7 +94,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -124,7 +124,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -154,7 +154,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -184,7 +184,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('blur_booking_codes', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-018
|
||||
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-032
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import IntegrationsTab from './IntegrationsTab';
|
||||
|
||||
function enableMcp() {
|
||||
@@ -40,6 +41,8 @@ beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
|
||||
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
|
||||
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
|
||||
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -69,18 +72,26 @@ describe('IntegrationsTab', () => {
|
||||
expect(codeEl!.textContent).toContain('/mcp');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered', async () => {
|
||||
it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered when expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
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');
|
||||
expect(preEl).not.toBeNull();
|
||||
expect(preEl!.textContent).toContain('mcpServers');
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<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.');
|
||||
});
|
||||
|
||||
@@ -95,8 +106,11 @@ describe('IntegrationsTab', () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
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('Other Token');
|
||||
});
|
||||
@@ -106,6 +120,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
const createBtn = screen.getByRole('button', { name: /Create New Token/i });
|
||||
await user.click(createBtn);
|
||||
await screen.findByText('Create API Token');
|
||||
@@ -116,6 +131,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
|
||||
@@ -127,6 +143,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
@@ -153,6 +170,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
@@ -182,6 +200,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test');
|
||||
@@ -206,6 +225,8 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
|
||||
@@ -230,6 +251,8 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Delete Me');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal
|
||||
@@ -289,6 +312,8 @@ describe('IntegrationsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await screen.findByText('Cancel Token');
|
||||
await user.click(screen.getByTitle('Delete Token'));
|
||||
await screen.findByRole('button', { name: /^Cancel$/i });
|
||||
@@ -319,6 +344,7 @@ describe('IntegrationsTab', () => {
|
||||
enableMcp();
|
||||
render(<IntegrationsTab />);
|
||||
await screen.findByText('MCP Configuration');
|
||||
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
|
||||
await user.click(screen.getByRole('button', { name: /Create New Token/i }));
|
||||
await screen.findByText('Create API Token');
|
||||
const input = screen.getByPlaceholderText(/Claude Desktop/i);
|
||||
@@ -328,4 +354,301 @@ describe('IntegrationsTab', () => {
|
||||
expect(postCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-INTEGRATIONS-019: default tab is OAuth 2.1 Clients — OAuth hint visible, token list hidden', async () => {
|
||||
enableMcp();
|
||||
render(<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 React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react'
|
||||
import { authApi } from '../../api/client'
|
||||
import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
|
||||
import { authApi, oauthApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||
|
||||
interface OAuthPreset {
|
||||
id: string
|
||||
label: string
|
||||
name: string
|
||||
uris: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
const OAUTH_PRESETS: OAuthPreset[] = [
|
||||
{
|
||||
id: 'claude-web',
|
||||
label: 'Claude.ai',
|
||||
name: 'Claude.ai',
|
||||
uris: 'https://claude.ai/api/mcp/auth_callback',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
label: 'Claude Desktop',
|
||||
name: 'Claude Desktop',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
name: 'Cursor',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
label: 'VS Code',
|
||||
name: 'VS Code / Copilot',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => s.endsWith(':read')),
|
||||
},
|
||||
{
|
||||
id: 'windsurf',
|
||||
label: 'Windsurf',
|
||||
name: 'Windsurf',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
label: 'Zed',
|
||||
name: 'Zed',
|
||||
uris: 'http://localhost',
|
||||
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
interface OAuthClient {
|
||||
id: string
|
||||
name: string
|
||||
client_id: string
|
||||
redirect_uris: string[]
|
||||
allowed_scopes: string[]
|
||||
created_at: string
|
||||
client_secret?: string // only present on create
|
||||
}
|
||||
|
||||
interface OAuthSession {
|
||||
id: number
|
||||
client_id: string
|
||||
client_name: string
|
||||
scopes: string[]
|
||||
access_token_expires_at: string
|
||||
refresh_token_expires_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface McpToken {
|
||||
id: number
|
||||
@@ -26,6 +101,28 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
loadAddons()
|
||||
}, [loadAddons])
|
||||
|
||||
// OAuth clients state
|
||||
const [oauthClients, setOauthClients] = useState<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
|
||||
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
|
||||
const [mcpModalOpen, setMcpModalOpen] = useState(false)
|
||||
@@ -34,8 +131,26 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const [mcpCreating, setMcpCreating] = useState(false)
|
||||
const [mcpDeleteId, setMcpDeleteId] = useState<number | 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 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 = `{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
@@ -85,10 +200,72 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
const handleCopy = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
// Load OAuth clients and sessions
|
||||
useEffect(() => {
|
||||
if (mcpEnabled) {
|
||||
oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {})
|
||||
oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {})
|
||||
}
|
||||
}, [mcpEnabled])
|
||||
|
||||
const handleCreateOAuthClient = async () => {
|
||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
||||
setOauthCreating(true)
|
||||
try {
|
||||
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
|
||||
setOauthCreatedClient(d.client)
|
||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||
setOauthNewName('')
|
||||
setOauthNewUris('')
|
||||
setOauthNewScopes([])
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.createError'))
|
||||
} finally {
|
||||
setOauthCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteOAuthClient = async (id: string) => {
|
||||
try {
|
||||
await oauthApi.clients.delete(id)
|
||||
setOauthClients(prev => prev.filter(c => c.id !== id))
|
||||
setOauthDeleteId(null)
|
||||
toast.success(t('settings.oauth.toast.deleted'))
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.deleteError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRotateSecret = async (id: string) => {
|
||||
setOauthRotating(true)
|
||||
try {
|
||||
const d = await oauthApi.clients.rotate(id)
|
||||
setOauthRotatedSecret(d.client_secret)
|
||||
setOauthRotateId(null)
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.rotateError'))
|
||||
} finally {
|
||||
setOauthRotating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevokeSession = async (id: number) => {
|
||||
try {
|
||||
await oauthApi.sessions.revoke(id)
|
||||
setOauthSessions(prev => prev.filter(s => s.id !== id))
|
||||
setOauthRevokeId(null)
|
||||
toast.success(t('settings.oauth.toast.revoked'))
|
||||
} catch {
|
||||
toast.error(t('settings.oauth.toast.revokeError'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhotoProvidersSection />
|
||||
@@ -109,63 +286,217 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON config box */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label>
|
||||
<button onClick={() => handleCopy(mcpJsonConfig, 'json')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||||
{copiedKey === 'json' ? 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>
|
||||
{/* Sub-tab bar */}
|
||||
<div className="flex gap-1 rounded-lg p-1" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||
<button
|
||||
onClick={() => setActiveMcpTab('oauth')}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
activeMcpTab === 'oauth' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}>
|
||||
{t('settings.oauth.clients')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveMcpTab('apitokens')}
|
||||
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2 ${
|
||||
activeMcpTab === 'apitokens' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||
}`}>
|
||||
{t('settings.mcp.apiTokens')}
|
||||
<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>
|
||||
|
||||
{/* Token list */}
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
{/* OAuth 2.1 Clients tab */}
|
||||
{activeMcpTab === 'oauth' && (
|
||||
<>
|
||||
{/* JSON config — OAuth (collapsible) */}
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setConfigOpenOAuth(o => !o)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
|
||||
style={{ background: 'var(--bg-secondary)' }}>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
|
||||
{configOpenOAuth ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
|
||||
</button>
|
||||
{configOpenOAuth && (
|
||||
<div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex justify-end mb-1.5">
|
||||
<button onClick={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||
{copiedKey === 'json-oauth' ? <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>
|
||||
<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>
|
||||
<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)' }}>
|
||||
{mcpJsonConfigOAuth}
|
||||
</pre>
|
||||
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHintOAuth')}</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -182,7 +513,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
|
||||
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
|
||||
autoFocus />
|
||||
</div>
|
||||
@@ -192,8 +523,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -217,8 +547,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white"
|
||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||
{t('settings.mcp.modal.done')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -248,6 +577,216 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
</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'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ describe('NotificationsTab', () => {
|
||||
http.get('/api/notifications/preferences', () => new Promise(() => {})),
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => {
|
||||
render(<NotificationsTab />);
|
||||
// The event label is translated; fallback is the key itself
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
// Should render a toggle (ToggleSwitch renders a button)
|
||||
const toggles = await screen.findAllByRole('button');
|
||||
@@ -52,7 +52,7 @@ describe('NotificationsTab', () => {
|
||||
it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => {
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
// inapp channel header should appear (either translated or raw key)
|
||||
const headers = screen.getAllByText(/inapp|in.?app/i);
|
||||
@@ -72,7 +72,7 @@ describe('NotificationsTab', () => {
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
// Should show noChannels message (translated or key)
|
||||
const noChannelEl = await screen.findByText(/no.*channel|noChannels/i);
|
||||
@@ -97,7 +97,7 @@ describe('NotificationsTab', () => {
|
||||
);
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
// A dash should appear for non-implemented combos
|
||||
const dashes = await screen.findAllByText('—');
|
||||
@@ -116,7 +116,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// minimalMatrix has inapp:true and email:false for trip_invite
|
||||
@@ -144,7 +144,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the inapp toggle for trip_invite — it starts as "on"
|
||||
@@ -156,8 +156,8 @@ describe('NotificationsTab', () => {
|
||||
|
||||
// After the error, the toggle should revert back (still rendered in the DOM)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The toggle should still be present (not removed on error)
|
||||
@@ -178,20 +178,20 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggleButtons = await screen.findAllByRole('button');
|
||||
await user.click(toggleButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Saving…')).toBeInTheDocument();
|
||||
expect(screen.getByText('Saving...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
resolveRequest();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Webhook URL input should be present
|
||||
@@ -238,7 +238,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
@@ -265,7 +265,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
@@ -297,7 +297,7 @@ describe('NotificationsTab', () => {
|
||||
|
||||
render(<NotificationsTab />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await screen.findByRole('textbox');
|
||||
@@ -330,7 +330,7 @@ describe('NotificationsTab', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
@@ -371,7 +371,7 @@ describe('NotificationsTab', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = await screen.findByRole('textbox');
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
@@ -119,7 +119,7 @@ export default function NotificationsTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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 }}>
|
||||
|
||||
@@ -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
|
||||
input_type: string
|
||||
placeholder?: string | null
|
||||
hint?: string | null
|
||||
required: boolean
|
||||
secret: boolean
|
||||
settings_key?: string | null
|
||||
@@ -71,6 +72,10 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const field of getProviderFields(provider)) {
|
||||
const payloadKey = field.payload_key || field.settings_key || field.key
|
||||
if (field.input_type === 'checkbox') {
|
||||
payload[payloadKey] = values[field.key] === 'true'
|
||||
continue
|
||||
}
|
||||
const value = (values[field.key] || '').trim()
|
||||
if (field.secret && !value) continue
|
||||
payload[payloadKey] = value
|
||||
@@ -102,6 +107,18 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
const cfg = getProviderConfig(provider)
|
||||
const fields = getProviderFields(provider)
|
||||
|
||||
// Seed checkbox defaults before the async settings load resolves
|
||||
const checkboxDefaults: Record<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) {
|
||||
apiClient.get(cfg.settings_get).then(res => {
|
||||
if (isCancelled) return
|
||||
@@ -112,7 +129,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
if (field.secret) continue
|
||||
const sourceKey = field.settings_key || field.payload_key || field.key
|
||||
const rawValue = (res.data as Record<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 => ({
|
||||
...prev,
|
||||
@@ -198,14 +221,31 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
<div className="space-y-3">
|
||||
{fields.map(field => (
|
||||
<div key={`${provider.id}-${field.key}`}>
|
||||
<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.input_type === 'checkbox' ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values[field.key] === 'true'}
|
||||
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
|
||||
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 className="flex items-center gap-3">
|
||||
@@ -213,7 +253,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
onClick={() => handleSaveProvider(provider)}
|
||||
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!canSave ? 'Save route is not configured for this provider' : isProviderSaveDisabled(provider) ? 'Please fill all required fields' : ''}
|
||||
title={!canSave ? t('memories.saveRouteNotConfigured') : isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''}
|
||||
>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
@@ -221,18 +261,23 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
||||
onClick={() => handleTestProvider(provider)}
|
||||
disabled={!canTest || testing}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||
title={!canTest ? 'Test route is not configured for this provider' : ''}
|
||||
title={!canTest ? t('memories.testRouteNotConfigured') : ''}
|
||||
>
|
||||
{testing
|
||||
? <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" />}
|
||||
{t('memories.testConnection')}
|
||||
</button>
|
||||
{connected && (
|
||||
{connected ? (
|
||||
<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" />
|
||||
{t('memories.connected')}
|
||||
</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>
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
|
||||
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
|
||||
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
|
||||
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : 'Error'))
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('common.error')))
|
||||
}
|
||||
|
||||
// Get category count (non-done items)
|
||||
@@ -479,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
due_date: dueDate || null, category: category || null,
|
||||
assigned_user_id: assignedUserId, priority,
|
||||
} as any)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -487,7 +487,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
||||
try {
|
||||
await deleteTodoItem(tripId, item.id)
|
||||
onClose()
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
|
||||
@@ -663,7 +663,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
|
||||
assigned_user_id: assignedUserId,
|
||||
} as any)
|
||||
if (item?.id) onCreated(item.id)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
|
||||
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
||||
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
|
||||
const [memberSelectValue, setMemberSelectValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,8 +75,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (!trip) {
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||
if (trip) {
|
||||
tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
|
||||
} else {
|
||||
setExistingMembers([])
|
||||
}
|
||||
}, [trip, isOpen])
|
||||
|
||||
@@ -365,12 +369,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members — only for new trips */}
|
||||
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||
{/* Members */}
|
||||
{allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||
<div>
|
||||
<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>
|
||||
{/* 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 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{selectedMembers.map(uid => {
|
||||
@@ -393,11 +423,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<CustomSelect
|
||||
value={memberSelectValue}
|
||||
onChange={value => {
|
||||
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') }
|
||||
onChange={async value => {
|
||||
if (!value) return
|
||||
if (isEditing && trip?.id) {
|
||||
const user = allUsers.find(u => u.id === Number(value))
|
||||
if (user) {
|
||||
try {
|
||||
await tripsApi.addMember(trip.id, user.username)
|
||||
setExistingMembers(prev => [...prev, { id: user.id, username: user.username }])
|
||||
toast.success(t('trips.memberAdded', { username: user.username }))
|
||||
} catch { toast.error(t('trips.memberAddError')) }
|
||||
}
|
||||
} else {
|
||||
setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)])
|
||||
}
|
||||
setMemberSelectValue('')
|
||||
}}
|
||||
placeholder={t('dashboard.addMember')}
|
||||
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))}
|
||||
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id) && !existingMembers.some(m => m.id === u.id)).map(u => ({ value: u.id, label: u.username }))}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -40,6 +40,11 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
|
||||
const toast = useToast()
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
shareApi.getLink(tripId).then(d => {
|
||||
@@ -77,7 +82,8 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
|
||||
if (shareUrl) {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('VacayCalendar', () => {
|
||||
expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', 42)
|
||||
})
|
||||
|
||||
it('FE-COMP-VACAYCALENDAR-007: cell click blocked by public holiday', async () => {
|
||||
it('FE-COMP-VACAYCALENDAR-007: cell click on public holiday toggles vacation entry', async () => {
|
||||
const user = userEvent.setup()
|
||||
const toggleEntry = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
@@ -168,10 +168,10 @@ describe('VacayCalendar', () => {
|
||||
|
||||
render(<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'))
|
||||
|
||||
expect(toggleEntry).not.toHaveBeenCalled()
|
||||
expect(toggleEntry).toHaveBeenCalledWith('2025-01-01', undefined)
|
||||
})
|
||||
|
||||
it('FE-COMP-VACAYCALENDAR-008: cell click in company mode calls toggleCompanyHoliday', async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import { useMemo, useState, useCallback, useEffect } from 'react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import VacayMonthCard from './VacayMonthCard'
|
||||
import { Building2, MousePointer2 } from 'lucide-react'
|
||||
|
||||
@@ -9,6 +10,30 @@ export default function VacayCalendar() {
|
||||
const { t } = useTranslation()
|
||||
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
|
||||
const [companyMode, setCompanyMode] = useState(false)
|
||||
const [tripDates, setTripDates] = useState<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 s = new Set()
|
||||
@@ -35,17 +60,16 @@ export default function VacayCalendar() {
|
||||
await toggleCompanyHoliday(dateStr)
|
||||
return
|
||||
}
|
||||
if (holidays[dateStr]) return
|
||||
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
|
||||
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||
await toggleEntry(dateStr, selectedUserId || undefined)
|
||||
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
||||
}, [companyMode, toggleEntry, toggleCompanyHoliday, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
||||
|
||||
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<VacayMonthCard
|
||||
key={i}
|
||||
@@ -59,6 +83,8 @@ export default function VacayCalendar() {
|
||||
companyMode={companyMode}
|
||||
blockWeekends={blockWeekends}
|
||||
weekendDays={weekendDays}
|
||||
tripDates={tripDates}
|
||||
weekStart={plan?.week_start ?? 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -23,22 +23,26 @@ interface VacayMonthCardProps {
|
||||
companyMode: boolean
|
||||
blockWeekends: boolean
|
||||
weekendDays?: number[]
|
||||
tripDates?: Set<string>
|
||||
weekStart?: number
|
||||
}
|
||||
|
||||
export default function VacayMonthCard({
|
||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
|
||||
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1
|
||||
}: VacayMonthCardProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
const weekdays = WEEKDAY_KEYS.map(k => t(k))
|
||||
const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const
|
||||
const orderedKeys = weekStart === 0 ? WEEKDAY_KEYS_SUNDAY : WEEKDAY_KEYS
|
||||
const weekdays = orderedKeys.map(k => t(k))
|
||||
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
|
||||
|
||||
|
||||
const weeks = useMemo(() => {
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
let startDow = firstDay.getDay() - 1
|
||||
if (startDow < 0) startDow = 6
|
||||
let startDow = firstDay.getDay() - weekStart
|
||||
if (startDow < 0) startDow += 7
|
||||
const cells = []
|
||||
for (let i = 0; i < startDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
@@ -50,6 +54,11 @@ export default function VacayMonthCard({
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
|
||||
const todayStr = useMemo(() => {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<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)' }}>
|
||||
@@ -57,11 +66,16 @@ export default function VacayMonthCard({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
{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)' }}>
|
||||
{wd}
|
||||
</div>
|
||||
))}
|
||||
{weekdays.map((wd, i) => {
|
||||
// Map column index back to JS day (0=Sun..6=Sat) to check if it's a weekend column
|
||||
const jsDay = (i + weekStart) % 7
|
||||
const isWeekendCol = weekendDays.includes(jsDay)
|
||||
return (
|
||||
<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>
|
||||
@@ -76,7 +90,8 @@ export default function VacayMonthCard({
|
||||
const holiday = holidays[dateStr]
|
||||
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
||||
const dayEntries = entryMap[dateStr] || []
|
||||
const isBlocked = !!holiday || (weekend && blockWeekends) || (isCompany && !companyMode)
|
||||
const isBlocked = (weekend && blockWeekends) || (isCompany && !companyMode)
|
||||
const isToday = dateStr === todayStr
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -122,9 +137,28 @@ export default function VacayMonthCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="relative z-[1] text-[11px] font-medium" style={{
|
||||
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||
{tripDates?.has(dateStr) && (
|
||||
<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,
|
||||
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}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -75,17 +75,7 @@ describe('VacaySettings', () => {
|
||||
render(<VacaySettings onClose={vi.fn()} />)
|
||||
|
||||
// Day buttons should be visible (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
|
||||
// They have text from translation keys; in test env they fallback to keys or English
|
||||
// Check that 7 day-selector buttons exist (they are inside the paddingLeft:36 div)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// The day buttons are not toggle buttons (no inline-flex/rounded-full class)
|
||||
const dayButtons = allButtons.filter(b =>
|
||||
!b.className.includes('inline-flex') &&
|
||||
!b.className.includes('rounded-full') &&
|
||||
!b.className.includes('rounded-md') &&
|
||||
!b.className.includes('rounded-xl') &&
|
||||
!b.className.includes('rounded-lg')
|
||||
)
|
||||
const dayButtons = within(screen.getByTestId('weekend-days')).getAllByRole('button')
|
||||
// There should be 7 day buttons
|
||||
expect(dayButtons.length).toBe(7)
|
||||
})
|
||||
@@ -98,14 +88,8 @@ describe('VacaySettings', () => {
|
||||
})
|
||||
render(<VacaySettings onClose={vi.fn()} />)
|
||||
|
||||
// When block_weekends is false, the day selector section is not rendered
|
||||
// There should only be toggle buttons (4 toggles), no day buttons
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// None of the buttons should be day selectors (they have borderRadius:8 inline style)
|
||||
const dayButtons = allButtons.filter(b =>
|
||||
b.style.borderRadius === '8px' && b.style.padding === '4px 10px'
|
||||
)
|
||||
expect(dayButtons).toHaveLength(0)
|
||||
// When block_weekends is false, the weekend-days container is not rendered
|
||||
expect(screen.queryByTestId('weekend-days')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-VACAYSETTINGS-005: clicking an active weekend day removes it', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
|
||||
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -51,7 +51,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
|
||||
{/* Weekend days selector */}
|
||||
{plan.block_weekends !== false && (
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
@@ -85,6 +85,37 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
</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 */}
|
||||
<SettingToggle
|
||||
icon={ArrowRightLeft}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function Modal({
|
||||
|
||||
return (
|
||||
<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' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
@@ -61,7 +61,7 @@ export default function Modal({
|
||||
<div
|
||||
className={`
|
||||
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
|
||||
`}
|
||||
style={{
|
||||
|
||||
@@ -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'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
@@ -28,18 +28,27 @@ const ICON_COLORS: Record<ToastType, string> = {
|
||||
|
||||
export function ToastContainer() {
|
||||
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 id = ++toastIdCounter
|
||||
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
const t1 = setTimeout(() => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
const t2 = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 400)
|
||||
timersRef.current.push(t2)
|
||||
}, duration)
|
||||
timersRef.current.push(t1)
|
||||
}
|
||||
|
||||
return id
|
||||
@@ -47,9 +56,10 @@ export function ToastContainer() {
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
|
||||
setTimeout(() => {
|
||||
const t = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 400)
|
||||
timersRef.current.push(t)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user