Merge branch 'dev' into feature/naver-support

This commit is contained in:
Marco Sadowski
2026-04-13 10:04:28 +02:00
committed by GitHub
220 changed files with 34926 additions and 3272 deletions
+188
View File
@@ -0,0 +1,188 @@
name: Build & Push Docker Image (Prerelease)
on:
push:
branches: [dev]
paths-ignore:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
inputs:
bump:
description: 'Bump line for next prerelease (auto detects in-flight major)'
type: choice
options: [auto, minor, major]
default: auto
permissions:
contents: write
concurrency:
group: prerelease-build
cancel-in-progress: false
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine prerelease version
id: bump
run: |
git fetch --tags
# Capture the exact commit we're building so build/merge jobs are pinned to it
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# Get latest stable tag (exclude prerelease tags)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
NEXT_MAJOR="$((MAJOR + 1)).0.0"
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
TARGET="$NEXT_MAJOR"
else
TARGET="${MAJOR}.$((MINOR + 1)).0"
fi
echo "Target: $TARGET"
# Find the highest existing prerelease N for this target and increment
LAST_N=$(git tag -l "v${TARGET}-pre.*" | sed 's/.*-pre\.//' | sort -n | tail -1)
N=$(( ${LAST_N:-0} + 1 ))
NEW_VERSION="${TARGET}-pre.${N}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION"
build:
runs-on: ${{ matrix.runner }}
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
docker buildx imagetools create \
-t "mauriceboe/trek:latest-pre" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre
- name: Push git tag
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "v$VERSION"
git push origin "v$VERSION"
- name: Clean up old prerelease tags
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
KEEP=20
VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
git fetch --tags
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
TOTAL=${#ALL_TAGS[@]}
DELETE_COUNT=$((TOTAL - KEEP))
if [ "$DELETE_COUNT" -gt 0 ]; then
for TAG in "${ALL_TAGS[@]:0:$DELETE_COUNT}"; do
echo "Deleting old prerelease tag: $TAG"
git push origin --delete "$TAG"
done
fi
+90 -28
View File
@@ -7,10 +7,24 @@ on:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
workflow_dispatch: workflow_dispatch:
inputs:
bump:
description: 'Force bump line (auto = patch/finalize as today)'
type: choice
options: [auto, patch, minor, major]
default: auto
confirm_major:
description: "Type MAJOR (all caps) to confirm a major release"
type: string
default: ''
permissions: permissions:
contents: write contents: write
concurrency:
group: stable-build
cancel-in-progress: false
jobs: jobs:
version-bump: version-bump:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -20,48 +34,79 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version - name: Determine bump type and update version
id: bump id: bump
run: | run: |
# Check if this push is a merge commit from dev branch git fetch --tags
COMMIT_MSG=$(git log -1 --pretty=%s)
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then # Derive version from git tags — no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
if [ "$BUMP_INPUT" = "major" ]; then
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
exit 1
fi
NEW_VERSION="$((MAJOR + 1)).0.0"
BUMP="major"
elif [ "$BUMP_INPUT" = "minor" ]; then
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
BUMP="minor" BUMP="minor"
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then elif [ "$BUMP_INPUT" = "patch" ]; then
BUMP="minor" NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
else
BUMP="patch" BUMP="patch"
else
# auto: finalize in-flight prerelease if one exists, else patch
if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}"
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump — it bypasses confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
# If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
fi fi
echo "Bump type: $BUMP" echo "Bump type: $BUMP"
# Read current version
CURRENT=$(node -p "require('./server/package.json').version")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
if [ "$BUMP" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$CURRENT → $NEW_VERSION ($BUMP)" echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update both package.json files # Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd .. cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd .. cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag # Commit and tag
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]" git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION" git tag "v$NEW_VERSION"
git push origin main --follow-tags git push origin main --follow-tags
@@ -100,6 +145,8 @@ jobs:
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
- name: Export digest - name: Export digest
run: | run: |
@@ -140,14 +187,29 @@ jobs:
- name: Create and push multi-arch manifest - name: Create and push multi-arch manifest
working-directory: /tmp/digests working-directory: /tmp/digests
run: | run: |
VERSION=${{ needs.version-bump.outputs.version }} VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
docker buildx imagetools create \ docker buildx imagetools create \
-t mauriceboe/trek:latest \ -t "mauriceboe/trek:latest" \
-t mauriceboe/trek:$VERSION \ -t "mauriceboe/trek:$MAJOR_TAG" \
-t mauriceboe/nomad:latest \ -t "mauriceboe/trek:$VERSION" \
-t mauriceboe/nomad:$VERSION \
"${digests[@]}" "${digests[@]}"
- name: Inspect manifest - name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest run: docker buildx imagetools inspect mauriceboe/trek:latest
release-helm:
runs-on: ubuntu-latest
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
+13 -9
View File
@@ -1,7 +1,7 @@
name: Enforce PR Target Branch name: Enforce PR Target Branch
on: on:
pull_request: pull_request_target:
types: [opened, reopened, edited, synchronize] types: [opened, reopened, edited, synchronize]
jobs: jobs:
@@ -9,6 +9,8 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
pull-requests: write pull-requests: write
issues: write
contents: read
steps: steps:
- name: Flag or clear wrong base branch - name: Flag or clear wrong base branch
@@ -63,14 +65,16 @@ jobs:
repo: context.repo.repo, repo: context.repo.repo,
name: 'wrong-base-branch', name: 'wrong-base-branch',
}); });
} catch { } catch (err) {
await github.rest.issues.createLabel({ if (err.status === 404) {
owner: context.repo.owner, await github.rest.issues.createLabel({
repo: context.repo.repo, owner: context.repo.owner,
name: 'wrong-base-branch', repo: context.repo.repo,
color: 'd73a4a', name: 'wrong-base-branch',
description: 'PR is targeting the wrong base branch', color: 'd73a4a',
}); description: 'PR is targeting the wrong base branch',
});
}
} }
await github.rest.issues.addLabels({ await github.rest.issues.addLabels({
+2
View File
@@ -27,6 +27,8 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
EXPOSE 3000 EXPOSE 3000
+115 -20
View File
@@ -9,6 +9,10 @@ structured API.
## Table of Contents ## Table of Contents
- [Setup](#setup) - [Setup](#setup)
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
- [Authentication](#authentication)
- [OAuth Scopes](#oauth-scopes)
- [Limitations & Important Notes](#limitations--important-notes) - [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only) - [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write) - [Tools (read-write)](#tools-read-write)
@@ -22,22 +26,51 @@ structured API.
### 1. Enable the MCP addon (admin) ### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp` An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings. endpoint returns `404` and the MCP section does not appear in user settings.
### 2. Create an API token ### 2. Connect your MCP client
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token: #### Option A: OAuth 2.1 (recommended)
1. Click **Create New Token** MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop") management required — just provide the server URL:
3. **Copy the token immediately** — it is shown only once and cannot be recovered
Each user can create up to **10 tokens**. ```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp"
]
}
}
}
```
### 3. Configure your MCP client > The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your **What happens automatically:**
`claude_desktop_config.json`: 1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
(`trekcs_` prefix) and fixed scopes that you define up front.
#### Option B: Static API Token (deprecated)
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
1. Go to **Settings > Integrations > MCP** and create an API token.
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
3. Add it to your `claude_desktop_config.json`:
```json ```json
{ {
@@ -55,7 +88,65 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
} }
``` ```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows). Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
static token will receive deprecation warnings in the AI client via server instructions and tool results.
Each user can create up to **10 static tokens**.
---
## Authentication
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
| Method | Token prefix | Access level | TTL | Notes |
|--------|-------------|-------------|-----|-------|
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
---
## OAuth Scopes
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
that match your granted scopes for that session.
| Scope | Permission | Group |
|-------|-----------|-------|
| `trips:read` | View trips & itineraries | Trips |
| `trips:write` | Edit trips & itineraries | Trips |
| `trips:delete` | Delete trips (irreversible) | Trips |
| `trips:share` | Manage share links | Trips |
| `places:read` | View places & map data | Places |
| `places:write` | Manage places | Places |
| `atlas:read` | View Atlas | Atlas |
| `atlas:write` | Manage Atlas | Atlas |
| `packing:read` | View packing lists | Packing |
| `packing:write` | Manage packing lists | Packing |
| `todos:read` | View to-do lists | To-dos |
| `todos:write` | Manage to-do lists | To-dos |
| `budget:read` | View budget | Budget |
| `budget:write` | Manage budget | Budget |
| `reservations:read` | View reservations | Reservations |
| `reservations:write` | Manage reservations | Reservations |
| `collab:read` | View collaboration | Collaboration |
| `collab:write` | Manage collaboration | Collaboration |
| `notifications:read` | View notifications | Notifications |
| `notifications:write` | Manage notifications | Notifications |
| `vacay:read` | View vacation plans | Vacation |
| `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather |
**Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled.
--- ---
@@ -68,10 +159,13 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. | | **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. | | **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. | | **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. | | **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. | | **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
| **Token limits** | Maximum 10 API tokens per user. | | **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. | | **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. | | **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. | | **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. |
@@ -356,11 +450,12 @@ trip in a single call.
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks. MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
| Prompt | Description | | Prompt | Description |
|-------------------|---------------------------------------------------------------------------------| |----------------------|---------------------------------------------------------------------------------|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. | | `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. | | `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. | | `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
--- ---
+35 -16
View File
@@ -9,7 +9,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a> <a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a> <a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a> <a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
@@ -77,7 +77,8 @@
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user - **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
### AI / MCP Integration ### AI / MCP Integration
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips - **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access
- **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation - **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation
- **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context - **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context
- **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled - **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
@@ -97,11 +98,23 @@
- **PWA**: vite-plugin-pwa + Workbox - **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`) - **Real-Time**: WebSocket (`ws`)
- **State**: Zustand - **State**: Zustand
- **Auth**: JWT + OIDC + TOTP (MFA) - **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional) - **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: Open-Meteo API (free, no key required) - **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react - **Icons**: lucide-react
## Helm (Kubernetes)
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](charts/README.md) for configuration options.
## Quick Start ## Quick Start
```bash ```bash
@@ -149,16 +162,16 @@ services:
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy # - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production. # - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For # - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs) # - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links - APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL # - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID # - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret # - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button # - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only) # - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users # - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role # - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM) # - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
@@ -166,8 +179,8 @@ services:
# - DEMO_MODE=false # Enable demo mode (resets data hourly) # - DEMO_MODE=false # Enable demo mode (resets data hourly)
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist # - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist # - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60) # - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5) # - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./uploads:/app/uploads - ./uploads:/app/uploads
@@ -180,7 +193,13 @@ services:
start_period: 15s start_period: 15s
``` ```
This example is aimed at reverse-proxy deployments. If you access TREK directly on `http://<host>:3000` without nginx, Caddy, Traefik, or another TLS-terminating proxy in front of it, set `FORCE_HTTPS=false` and remove `TRUST_PROXY` to avoid redirects to a non-existent HTTPS endpoint. This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
```bash ```bash
docker compose up -d docker compose up -d
@@ -291,9 +310,9 @@ trek.yourdomain.com {
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy. If you access TREK directly on `http://host:3000`, keep this `false`. | `false` | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For`. Use this only when TREK is actually behind a reverse proxy. | `1` | | `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — | | `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| **OIDC / SSO** | | | | **OIDC / SSO** | | |
@@ -301,7 +320,7 @@ trek.yourdomain.com {
| `OIDC_CLIENT_ID` | OIDC client ID | — | | `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — | | `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` | | `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` | | `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — | | `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — | | `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` | | `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
@@ -311,8 +330,8 @@ trek.yourdomain.com {
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random | | `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| **Other** | | | | **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | | `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` | | `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` | | `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
## Optional API Keys ## Optional API Keys
+15 -1
View File
@@ -10,8 +10,20 @@ This is a minimal Helm chart for deploying the TREK app.
- Optional generic Ingress support - Optional generic Ingress support
- Health checks on `/api/health` - Health checks on `/api/health`
## Helm Repository
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
## Usage ## Usage
Or install directly from the local chart:
```sh ```sh
helm install trek ./chart \ helm install trek ./chart \
--set ingress.enabled=true \ --set ingress.enabled=true \
@@ -32,5 +44,7 @@ See `values.yaml` for more options.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC. - `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically. - If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless. - Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases. - `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path. - Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 0.1.0 version: 2.9.13
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "latest" appVersion: "2.9.13"
@@ -27,7 +27,7 @@ spec:
fsGroup: 1000 fsGroup: 1000
containers: containers:
- name: trek - name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }} imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.resources }} {{- with .Values.resources }}
resources: resources:
+11 -9
View File
@@ -1,7 +1,7 @@
image: image:
repository: mauriceboe/trek repository: mauriceboe/trek
tag: latest # tag: latest
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
# Optional image pull secrets for private registries # Optional image pull secrets for private registries
@@ -25,11 +25,11 @@ env:
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP. # Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
# Also used as the base URL for links in email notifications and other external links. # Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false" # FORCE_HTTPS: "false"
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy. # Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
# COOKIE_SECURE: "true" # COOKIE_SECURE: "true"
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production. # Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# TRUST_PROXY: "1" # TRUST_PROXY: "1"
# Number of trusted reverse proxies for X-Forwarded-For header parsing. # Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false" # ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address. # Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked. # Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
@@ -40,7 +40,9 @@ env:
# OIDC_DISPLAY_NAME: "SSO" # OIDC_DISPLAY_NAME: "SSO"
# Label shown on the SSO login button. # Label shown on the SSO login button.
# OIDC_ONLY: "false" # OIDC_ONLY: "false"
# Set to "true" to disable local password auth entirely (first SSO login becomes admin). # Set to "true" to force SSO-only mode: disables password login and password registration.
# Overrides the granular toggles in Admin > Settings and cannot be changed at runtime.
# First SSO login becomes admin on a fresh instance.
# OIDC_ADMIN_CLAIM: "" # OIDC_ADMIN_CLAIM: ""
# OIDC claim used to identify admin users. # OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: "" # OIDC_ADMIN_VALUE: ""
@@ -51,10 +53,10 @@ env:
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik). # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# DEMO_MODE: "false" # DEMO_MODE: "false"
# Enable demo mode (hourly data resets). # Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "60" # MCP_RATE_LIMIT: "300"
# Max MCP API requests per user per minute. Defaults to 60. # Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "5" # MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 5. # Max concurrent MCP sessions per user. Defaults to 20.
# Secret environment variables stored in a Kubernetes Secret. # Secret environment variables stored in a Kubernetes Secret.
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title> <title>TREK</title>
<!-- PWA / iOS --> <!-- PWA / iOS -->
+1591 -1840
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.9.12", "version": "2.9.13",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -19,6 +19,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"marked": "^18.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
@@ -40,7 +41,7 @@
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^4.1.2", "@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.18",
"jsdom": "^29.0.1", "jsdom": "^29.0.1",
"msw": "^2.13.0", "msw": "^2.13.0",
@@ -50,6 +51,6 @@
"typescript": "^6.0.2", "typescript": "^6.0.2",
"vite": "^5.1.4", "vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0", "vite-plugin-pwa": "^0.21.0",
"vitest": "^4.1.2" "vitest": "^3.2.4"
} }
} }
+35 -4
View File
@@ -10,9 +10,14 @@ import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage' import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage' import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage'
import JourneyPublicPage from './pages/JourneyPublicPage'
import SharedTripPage from './pages/SharedTripPage' import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast' import { ToastContainer } from './components/shared/Toast'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n' import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client' import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
@@ -60,7 +65,12 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
return <Navigate to="/dashboard" replace /> return <Navigate to="/dashboard" replace />
} }
return <>{children}</> return (
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
)
} }
function RootRedirect() { function RootRedirect() {
@@ -78,16 +88,18 @@ function RootRedirect() {
} }
export default function App() { export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore() const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore() const { loadSettings } = useSettingsStore()
useEffect(() => { useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) { if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
loadUser() loadUser()
} }
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => { authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true) if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true) if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone) if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
@@ -162,7 +174,10 @@ export default function App() {
<Route path="/" element={<RootRedirect />} /> <Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} /> <Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} /> <Route path="/register" element={<LoginPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
@@ -219,6 +234,22 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/journey"
element={
<ProtectedRoute>
<JourneyPage />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute>
<JourneyDetailPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/notifications" path="/notifications"
element={ element={
+123 -3
View File
@@ -1,7 +1,36 @@
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import { getSocketId } from './websocket' import { getSocketId } from './websocket'
import en from '../i18n/translations/en'
import br from '../i18n/translations/br'
import de from '../i18n/translations/de'
import es from '../i18n/translations/es'
import fr from '../i18n/translations/fr'
import it from '../i18n/translations/it'
import nl from '../i18n/translations/nl'
import pl from '../i18n/translations/pl'
import cs from '../i18n/translations/cs'
import hu from '../i18n/translations/hu'
import ru from '../i18n/translations/ru'
import zh from '../i18n/translations/zh'
import zhTw from '../i18n/translations/zhTw'
import ar from '../i18n/translations/ar'
const apiClient: AxiosInstance = axios.create({ const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
}
function translateRateLimit(): string {
const fallback = 'Too many attempts. Please try again later.'
try {
const lang = localStorage.getItem('app_language') || 'en'
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
} catch {
return fallback
}
}
export const apiClient: AxiosInstance = axios.create({
baseURL: '/api', baseURL: '/api',
withCredentials: true, withCredentials: true,
headers: { headers: {
@@ -21,12 +50,12 @@ apiClient.interceptors.request.use(
(error) => Promise.reject(error) (error) => Promise.reject(error)
) )
// Response interceptor - handle 401 // Response interceptor - handle 401, 403 MFA, 429 rate limit
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) { if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
const currentPath = window.location.pathname + window.location.search const currentPath = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath) window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
} }
@@ -38,6 +67,16 @@ apiClient.interceptors.response.use(
) { ) {
window.location.href = '/settings?mfa=required' window.location.href = '/settings?mfa=required'
} }
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
} else {
error.response.data = { error: translated }
}
error.message = translated
}
return Promise.reject(error) return Promise.reject(error)
} }
) )
@@ -72,6 +111,43 @@ export const authApi = {
}, },
} }
export const oauthApi = {
/** Validate OAuth authorize params — called by consent page on load */
validate: (params: {
response_type: string
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */
authorize: (body: {
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
approved: boolean
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
},
sessions: {
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
},
}
export const tripsApi = { export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data), list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data), create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
@@ -197,6 +273,8 @@ export const adminApi = {
apiClient.get('/admin/audit-log', { params }).then(r => r.data), apiClient.get('/admin/audit-log', { params }).then(r => r.data),
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data), mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data), deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data),
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data), getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data), updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data), rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
@@ -210,6 +288,48 @@ export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data), enabled: () => apiClient.get('/addons').then(r => r.data),
} }
export const journeyApi = {
list: () => apiClient.get('/journeys').then(r => r.data),
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data),
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
// Trips (sync sources)
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
// Entries
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
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),
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
}
export const mapsApi = { export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
+102
View File
@@ -0,0 +1,102 @@
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
import { describe, it, expect } from 'vitest'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
describe('SCOPE_GROUPS', () => {
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
const expected = [
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
'places:read', 'places:write',
'atlas:read', 'atlas:write',
'packing:read', 'packing:write',
'todos:read', 'todos:write',
'budget:read', 'budget:write',
'reservations:read', 'reservations:write',
'collab:read', 'collab:write',
'notifications:read', 'notifications:write',
'vacay:read', 'vacay:write',
'geo:read', 'weather:read',
]
for (const scope of expected) {
expect(SCOPE_GROUPS).toHaveProperty(scope)
}
})
it('FE-OAUTH-SCOPES-002: each scope entry has labelKey, descriptionKey, groupKey', () => {
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
expect(keys.labelKey, `${scope} missing labelKey`).toBeTruthy()
expect(keys.descriptionKey, `${scope} missing descriptionKey`).toBeTruthy()
expect(keys.groupKey, `${scope} missing groupKey`).toBeTruthy()
}
})
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
expect(ALL_SCOPES).toHaveLength(24)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
expect(ALL_SCOPES).toEqual(Object.keys(SCOPE_GROUPS))
})
})
describe('SCOPE_GROUP_NAMES', () => {
it('FE-OAUTH-SCOPES-005: contains no duplicate group names', () => {
expect(SCOPE_GROUP_NAMES).toHaveLength(new Set(SCOPE_GROUP_NAMES).size)
})
it('FE-OAUTH-SCOPES-006: contains expected groups', () => {
const expected = [
'oauth.scope.group.trips',
'oauth.scope.group.places',
'oauth.scope.group.packing',
'oauth.scope.group.budget',
]
for (const g of expected) {
expect(SCOPE_GROUP_NAMES).toContain(g)
}
})
})
describe('getScopesByGroup', () => {
const identity = (key: string) => key
it('FE-OAUTH-SCOPES-007: groups all scopes under the correct group key', () => {
const groups = getScopesByGroup(identity)
// Every scope must appear exactly once across all groups
const allScopesInGroups = Object.values(groups).flat().map(s => s.scope)
expect(allScopesInGroups).toHaveLength(ALL_SCOPES.length)
for (const scope of ALL_SCOPES) {
expect(allScopesInGroups).toContain(scope)
}
})
it('FE-OAUTH-SCOPES-008: each item has scope, label, description, group', () => {
const groups = getScopesByGroup(identity)
for (const items of Object.values(groups)) {
for (const item of items) {
expect(item.scope).toBeTruthy()
expect(item.label).toBeTruthy()
expect(item.description).toBeTruthy()
expect(item.group).toBeTruthy()
}
}
})
it('FE-OAUTH-SCOPES-009: trips group contains trips:read and trips:write', () => {
const groups = getScopesByGroup(identity)
const tripsGroup = groups['oauth.scope.group.trips']
expect(tripsGroup).toBeDefined()
const scopeNames = tripsGroup.map(s => s.scope)
expect(scopeNames).toContain('trips:read')
expect(scopeNames).toContain('trips:write')
})
it('FE-OAUTH-SCOPES-010: uses translated group name as key', () => {
const t = (key: string) => key === 'oauth.scope.group.trips' ? 'Trips' : key
const groups = getScopesByGroup(t)
expect(groups['Trips']).toBeDefined()
expect(groups['oauth.scope.group.trips']).toBeUndefined()
})
})
+56
View File
@@ -0,0 +1,56 @@
// Human-readable scope definitions for the OAuth consent page.
// Must stay in sync with server/src/mcp/scopes.ts
export interface ScopeInfo {
label: string
description: string
group: string
}
export interface ScopeKeys {
labelKey: string
descriptionKey: string
groupKey: string
}
export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'trips:read': { labelKey: 'oauth.scope.trips:read.label', descriptionKey: 'oauth.scope.trips:read.description', groupKey: 'oauth.scope.group.trips' },
'trips:write': { labelKey: 'oauth.scope.trips:write.label', descriptionKey: 'oauth.scope.trips:write.description', groupKey: 'oauth.scope.group.trips' },
'trips:delete': { labelKey: 'oauth.scope.trips:delete.label', descriptionKey: 'oauth.scope.trips:delete.description', groupKey: 'oauth.scope.group.trips' },
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
'packing:write': { labelKey: 'oauth.scope.packing:write.label', descriptionKey: 'oauth.scope.packing:write.description', groupKey: 'oauth.scope.group.packing' },
'todos:read': { labelKey: 'oauth.scope.todos:read.label', descriptionKey: 'oauth.scope.todos:read.description', groupKey: 'oauth.scope.group.todos' },
'todos:write': { labelKey: 'oauth.scope.todos:write.label', descriptionKey: 'oauth.scope.todos:write.description', groupKey: 'oauth.scope.group.todos' },
'budget:read': { labelKey: 'oauth.scope.budget:read.label', descriptionKey: 'oauth.scope.budget:read.description', groupKey: 'oauth.scope.group.budget' },
'budget:write': { labelKey: 'oauth.scope.budget:write.label', descriptionKey: 'oauth.scope.budget:write.description', groupKey: 'oauth.scope.group.budget' },
'reservations:read': { labelKey: 'oauth.scope.reservations:read.label', descriptionKey: 'oauth.scope.reservations:read.description', groupKey: 'oauth.scope.group.reservations' },
'reservations:write': { labelKey: 'oauth.scope.reservations:write.label', descriptionKey: 'oauth.scope.reservations:write.description', groupKey: 'oauth.scope.group.reservations' },
'collab:read': { labelKey: 'oauth.scope.collab:read.label', descriptionKey: 'oauth.scope.collab:read.description', groupKey: 'oauth.scope.group.collab' },
'collab:write': { labelKey: 'oauth.scope.collab:write.label', descriptionKey: 'oauth.scope.collab:write.description', groupKey: 'oauth.scope.group.collab' },
'notifications:read': { labelKey: 'oauth.scope.notifications:read.label', descriptionKey: 'oauth.scope.notifications:read.description', groupKey: 'oauth.scope.group.notifications' },
'notifications:write': { labelKey: 'oauth.scope.notifications:write.label', descriptionKey: 'oauth.scope.notifications:write.description', groupKey: 'oauth.scope.group.notifications' },
'vacay:read': { labelKey: 'oauth.scope.vacay:read.label', descriptionKey: 'oauth.scope.vacay:read.description', groupKey: 'oauth.scope.group.vacay' },
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
}
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
// Group all scopes for the client registration form
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
export function getScopesByGroup(t: (key: string) => string): Record<string, Array<{ scope: string } & ScopeInfo>> {
const groups: Record<string, Array<{ scope: string } & ScopeInfo>> = {}
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
const group = t(keys.groupKey)
if (!groups[group]) groups[group] = []
groups[group].push({ scope, label: t(keys.labelKey), description: t(keys.descriptionKey), group })
}
return groups
}
+2 -2
View File
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass } from 'lucide-react'
const ICON_MAP = { const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass,
} }
interface Addon { interface Addon {
@@ -1,4 +1,4 @@
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-010 // FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
import { render, screen, waitFor } from '../../../tests/helpers/render'; import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -197,4 +197,127 @@ describe('AdminMcpTokensPanel', () => {
render(<><ToastContainer /><AdminMcpTokensPanel /></>); render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens'); await screen.findByText('Failed to load tokens');
}); });
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/oauth-sessions', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ sessions: [] });
})
);
render(<AdminMcpTokensPanel />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({ sessions: [] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('No active OAuth sessions');
});
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{
id: 1,
client_name: 'Claude Desktop',
username: 'alice',
scopes: ['trips:read', 'budget:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('Claude Desktop');
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('trips:read')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
const user = userEvent.setup();
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
],
})
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('App');
// "+1 more" button should appear
const moreBtn = await screen.findByText(/\+1 more/);
expect(moreBtn).toBeInTheDocument();
await user.click(moreBtn);
// After expand, "show less" appears
expect(await screen.findByText('show less')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/admin/oauth-sessions/5', () =>
HttpResponse.json({ success: true })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Revoke Me');
// Click the revoke (trash) button next to the session
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
// Confirmation modal opens
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/admin/oauth-sessions/6', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Error Session');
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to revoke session');
});
}); });
@@ -1,9 +1,21 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { adminApi } from '../../api/client' import { adminApi } from '../../api/client'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Key, Trash2, User, Loader2 } from 'lucide-react' import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
interface AdminOAuthSession {
id: number
client_id: string
client_name: string
user_id: number
username: string
scopes: string[]
access_token_expires_at: string
refresh_token_expires_at: string
created_at: string
}
interface AdminMcpToken { interface AdminMcpToken {
id: number id: number
name: string name: string
@@ -14,21 +26,49 @@ interface AdminMcpToken {
username: string username: string
} }
const SCOPES_PREVIEW = 6
export default function AdminMcpTokensPanel() { export default function AdminMcpTokensPanel() {
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [tokens, setTokens] = useState<AdminMcpToken[]>([]) const [tokens, setTokens] = useState<AdminMcpToken[]>([])
const [isLoading, setIsLoading] = useState(true) const [tokensLoading, setTokensLoading] = useState(true)
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null) const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
const toggleScopes = (id: number) =>
setExpandedScopes(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
useEffect(() => { useEffect(() => {
setIsLoading(true) adminApi.oauthSessions()
.then(d => setSessions(d.sessions || []))
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
.finally(() => setSessionsLoading(false))
adminApi.mcpTokens() adminApi.mcpTokens()
.then(d => setTokens(d.tokens || [])) .then(d => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError'))) .catch(() => toast.error(t('admin.mcpTokens.loadError')))
.finally(() => setIsLoading(false)) .finally(() => setTokensLoading(false))
}, []) }, [])
const handleRevoke = async (id: number) => {
try {
await adminApi.revokeOAuthSession(id)
setSessions(prev => prev.filter(s => s.id !== id))
setRevokeConfirmId(null)
toast.success(t('admin.oauthSessions.revokeSuccess'))
} catch {
toast.error(t('admin.oauthSessions.revokeError'))
}
}
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
await adminApi.deleteMcpToken(id) await adminApi.deleteMcpToken(id)
@@ -47,55 +87,156 @@ export default function AdminMcpTokensPanel() {
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p> <p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
</div> </div>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}> {/* OAuth Sessions */}
{isLoading ? ( <div>
<div className="flex items-center justify-center py-12"> <h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} /> <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
</div> {sessionsLoading ? (
) : tokens.length === 0 ? ( <div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center justify-center py-12 gap-2"> <Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
</div> </div>
{tokens.map((token, i) => ( ) : sessions.length === 0 ? (
<div key={token.id} <div className="flex flex-col items-center justify-center py-12 gap-2">
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3" <Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}> <p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
<div className="min-w-0"> </div>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p> ) : (
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p> <>
</div> <div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}> style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" /> <span>{t('admin.oauthSessions.clientName')}</span>
<span className="whitespace-nowrap">{token.username}</span> <span>{t('admin.oauthSessions.owner')}</span>
</div> <span className="text-right">{t('admin.oauthSessions.created')}</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}> <span></span>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
</div> </div>
))} {sessions.map((session, i) => {
</> const expanded = expandedScopes.has(session.id)
)} const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
const hidden = session.scopes.length - SCOPES_PREVIEW
return (
<div key={session.id}
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{visible.map(scope => (
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
{scope}
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
show less
</button>
)}
</div>
</div>
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{session.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{new Date(session.created_at).toLocaleDateString(locale)}
</span>
<button onClick={() => setRevokeConfirmId(session.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
)
})}
</>
)}
</div>
</div> </div>
{/* MCP Tokens */}
<div>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{tokensLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : tokens.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
</div>
{tokens.map((token, i) => (
<div key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</>
)}
</div>
</div>
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleRevoke(revokeConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
{/* Delete MCP token modal */}
{deleteConfirmId !== null && ( {deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }} <div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
@@ -133,7 +133,7 @@ describe('GitHubPanel', () => {
server.use( server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])), http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
); );
render(<GitHubPanel />); render(<GitHubPanel isPrerelease={true} />);
await screen.findByText('v3.0.0-beta.1'); await screen.findByText('v3.0.0-beta.1');
expect(screen.getByText('Pre-release')).toBeInTheDocument(); expect(screen.getByText('Pre-release')).toBeInTheDocument();
}); });
+11 -5
View File
@@ -6,12 +6,18 @@ import apiClient from '../../api/client'
const REPO = 'mauriceboe/TREK' const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10 const PER_PAGE = 10
export default function GitHubPanel() { interface GithubRelease {
id: number
prerelease: boolean
[key: string]: unknown
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation() const { t, language } = useTranslation()
const [releases, setReleases] = useState([]) const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState({}) const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
@@ -273,7 +279,7 @@ export default function GitHubPanel() {
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} /> <div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
<div className="space-y-0"> <div className="space-y-0">
{releases.map((release, idx) => { {(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0 const isLatest = idx === 0
const isExpanded = expanded[release.id] const isExpanded = expanded[release.id]
@@ -1,4 +1,4 @@
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-020 // FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
import { render, screen, waitFor } from '../../../tests/helpers/render'; import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -6,6 +6,7 @@ import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import BudgetPanel from './BudgetPanel'; import BudgetPanel from './BudgetPanel';
@@ -418,4 +419,80 @@ describe('BudgetPanel', () => {
// Grand total card shows 300.00 // Grand total card shows 300.00
expect(screen.getByText('300.00')).toBeInTheDocument(); expect(screen.getByText('300.00')).toBeInTheDocument();
}); });
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
// Use a user with id != 1 so they're not the owner
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Read Only Item');
// In read-only mode the Delete button should not be visible
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
});
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Train');
// expense_date is rendered as plain text in read-only mode
await screen.findByText('2025-06-15');
});
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
],
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
})
),
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
);
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
{ id: 2, username: 'bob', avatar_url: null },
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Lunch');
// Trigger settlement display
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
await user.click(settlementBtn);
await screen.findByText('alice');
// Avatar image should be rendered for alice
const avatarImg = screen.getAllByRole('img');
expect(avatarImg.length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Snack');
// When expense_date is null, the fallback '—' is shown
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThan(0);
});
}); });
+11 -7
View File
@@ -956,15 +956,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} /> <PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
{pieSegments.map(seg => { {pieSegments.map((seg, i) => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return ( return (
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} /> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span> <div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span> <span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
</div>
</div> </div>
) )
})} })}
+8 -2
View File
@@ -370,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const [showEmoji, setShowEmoji] = useState(false) const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y } const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set()) const [deletingIds, setDeletingIds] = useState(new Set())
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => { deleteTimersRef.current.forEach(clearTimeout) }
}, [])
const containerRef = useRef(null) const containerRef = useRef(null)
const messagesRef = useRef(messages) const messagesRef = useRef(messages)
@@ -483,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId)) setDeletingIds(prev => new Set(prev).add(msgId))
}) })
setTimeout(async () => { const t = setTimeout(async () => {
try { try {
await collabApi.deleteMessage(tripId, msgId) await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m)) setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
} catch {} } catch {}
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s }) setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400) }, 400)
deleteTimersRef.current.push(t)
}, [tripId]) }, [tripId])
const handleReact = useCallback(async (msgId, emoji) => { const handleReact = useCallback(async (msgId, emoji) => {
@@ -762,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
)} )}
{/* Composer */} {/* Composer */}
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}> <div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
{/* Reply preview */} {/* Reply preview */}
{replyTo && ( {replyTo && (
<div style={{ <div style={{
@@ -16,12 +16,13 @@ function formatTime(timeStr, is12h) {
} }
function formatDayLabel(date, t, locale) { function formatDayLabel(date, t, locale) {
const d = new Date(date + 'T00:00:00')
const now = new Date() const now = new Date()
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1) const nowDate = now.toISOString().split('T')[0]
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today' if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow' if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
} }
+1 -1
View File
@@ -778,7 +778,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
title={previewFile.original_name} title={previewFile.original_name}
> >
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}> <p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button> <button onClick={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' }}>{t('files.downloadPdf')}</button>
</p> </p>
</object> </object>
</div> </div>
@@ -0,0 +1,39 @@
// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import JournalBody from './JournalBody';
describe('JournalBody', () => {
it('FE-COMP-JOURNALBODY-001: renders plain text content', () => {
render(<JournalBody text="Hello traveller" />);
expect(screen.getByText('Hello traveller')).toBeInTheDocument();
});
it('FE-COMP-JOURNALBODY-002: renders bold markdown as <strong>', () => {
const { container } = render(<JournalBody text="This is **bold** text" />);
const strong = container.querySelector('strong');
expect(strong).toBeInTheDocument();
expect(strong!.textContent).toBe('bold');
});
it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => {
render(<JournalBody text="[Visit](https://example.com)" />);
const link = screen.getByRole('link', { name: 'Visit' });
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
const { container } = render(<JournalBody text="## Section Title" />);
const h2 = container.querySelector('h2');
expect(h2).toBeInTheDocument();
expect(h2!.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,69 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
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]}
components={{
h1: ({ children }) => <h1 style={{ fontFamily: 'inherit', fontSize: '1.3em', fontWeight: 700, margin: '16px 0 6px', lineHeight: 1.3 }}>{children}</h1>,
h2: ({ children }) => <h2 style={{ fontFamily: 'inherit', fontSize: '1.15em', fontWeight: 600, margin: '14px 0 4px', lineHeight: 1.3 }}>{children}</h2>,
h3: ({ children }) => <h3 style={{ fontFamily: 'inherit', fontSize: '1.05em', fontWeight: 600, margin: '12px 0 4px', lineHeight: 1.4 }}>{children}</h3>,
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}
</ReactMarkdown>
</div>
)
}
@@ -0,0 +1,229 @@
// 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(),
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,299 @@
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: false,
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 }).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,81 @@
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 }
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: 'line', prefix: '\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 {
// 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,97 @@
// 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(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import PhotoLightbox from './PhotoLightbox';
const samplePhotos = [
{ id: 'p1', src: '/photos/1.jpg', caption: 'Sunset at the beach' },
{ id: 'p2', src: '/photos/2.jpg', caption: 'Mountain trail' },
{ id: 'p3', src: '/photos/3.jpg', caption: null },
];
beforeEach(() => {
resetAllStores();
});
describe('PhotoLightbox', () => {
it('FE-COMP-LIGHTBOX-001: renders without crashing when open', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-002: shows photo image', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
const img = screen.getByRole('img');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', '/photos/1.jpg');
});
it('FE-COMP-LIGHTBOX-003: shows close button', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
const buttons = screen.getAllByRole('button');
// Close button exists (the X button in the top bar)
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-LIGHTBOX-004: previous/next navigation works', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
// Initially shows photo 1
expect(screen.getByText('1 / 3')).toBeInTheDocument();
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/photos/1.jpg');
// Navigate to next photo via ArrowRight key
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/2.jpg');
// Navigate back via ArrowLeft key
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(screen.getByText('1 / 3')).toBeInTheDocument();
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/1.jpg');
});
it('FE-COMP-LIGHTBOX-005: keyboard Escape closes lightbox', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-007: does not render when photos array is empty', () => {
const onClose = vi.fn();
const { container } = render(<PhotoLightbox photos={[]} onClose={onClose} />);
// Component returns null when photo is undefined (empty array, index 0 is undefined)
expect(container.querySelector('img')).not.toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-008: calls onClose when close button clicked', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
// The close button is in the top bar — find the button and click it
const buttons = screen.getAllByRole('button');
// The first button in the top bar is the close (X) button
buttons[0].click();
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,149 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
interface LightboxPhoto {
id: string
src: string
caption?: string | null
provider?: string
asset_id?: string | null
owner_id?: number | null
}
interface Props {
photos: LightboxPhoto[]
startIndex?: number
onClose: () => void
}
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
const [idx, setIdx] = useState(startIndex)
const touchStart = useRef<{ x: number; y: number } | null>(null)
const photo = photos[idx]
const hasPrev = idx > 0
const hasNext = idx < photos.length - 1
const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev])
const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [prev, next, onClose])
const onTouchStart = (e: React.TouchEvent) => {
const t = e.touches[0]
touchStart.current = { x: t.clientX, y: t.clientY }
}
const onTouchEnd = (e: React.TouchEvent) => {
if (!touchStart.current) return
const t = e.changedTouches[0]
const dx = t.clientX - touchStart.current.x
const dy = t.clientY - touchStart.current.y
// swipe down to close
if (dy > 80 && Math.abs(dx) < 60) {
onClose()
return
}
// horizontal swipe
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
if (dx < 0) next()
else prev()
}
touchStart.current = null
}
if (!photo) return null
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 500,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Photo area — centered with nav overlays */}
<div
className="group/lightbox"
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
>
{/* Top bar */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
{idx + 1} / {photos.length}
</span>
<button onClick={onClose} style={{
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<X size={18} />
</button>
</div>
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && (
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronLeft size={22} />
</button>
)}
{/* Photo */}
<img
key={photo.id}
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
{/* Next button */}
{hasNext && (
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronRight size={22} />
</button>
)}
{/* Caption — bottom center overlay */}
{photo.caption && (
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{
fontSize: 14, fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10,
}}>{photo.caption}</p>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,69 @@
// FE-COMP-MOOD-001 to FE-COMP-MOOD-005
import { describe, it, expect } from 'vitest';
import { MOODS, WEATHERS, getMood, moodColor, tagColors, TAG_STYLES, MOOD_DEFAULT_COLOR } from './moodConfig';
describe('moodConfig', () => {
it('FE-COMP-MOOD-001: MOODS contains all five mood definitions', () => {
const ids = MOODS.map(m => m.id);
expect(ids).toEqual(['amazing', 'good', 'neutral', 'tired', 'rough']);
expect(MOODS).toHaveLength(5);
});
it('FE-COMP-MOOD-002: every mood has valid hex color and css var', () => {
for (const mood of MOODS) {
expect(mood.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(mood.cssVar).toMatch(/^var\(--mood-.+\)$/);
expect(mood.icon).toBeDefined();
expect(mood.label).toBeTruthy();
}
});
it('FE-COMP-MOOD-003: getMood returns correct mood or undefined', () => {
expect(getMood('amazing')?.id).toBe('amazing');
expect(getMood('rough')?.color).toBe('#9B8EC4');
expect(getMood('nonexistent')).toBeUndefined();
expect(getMood(null)).toBeUndefined();
expect(getMood(undefined)).toBeUndefined();
});
it('FE-COMP-MOOD-004: moodColor returns css var or fallback', () => {
expect(moodColor('good')).toBe('var(--mood-good)');
expect(moodColor(null)).toBe('var(--journal-faint)');
expect(moodColor('unknown')).toBe('var(--journal-faint)');
});
it('FE-COMP-MOOD-005: WEATHERS contains all eight entries with icons', () => {
expect(WEATHERS).toHaveLength(8);
const ids = WEATHERS.map(w => w.id);
expect(ids).toContain('sunny');
expect(ids).toContain('snowy');
expect(ids).toContain('stormy');
for (const w of WEATHERS) {
expect(w.icon).toBeDefined();
expect(w.label).toBeTruthy();
}
});
});
describe('tagColors', () => {
it('FE-COMP-MOOD-006: returns known tag colors for light and dark mode', () => {
const light = tagColors('hidden gem', false);
expect(light.bg).toBe('#dcfce7');
expect(light.fg).toBe('#166534');
const dark = tagColors('hidden gem', true);
expect(dark.bg).toBe('rgba(22,101,52,0.2)');
expect(dark.fg).toBe('#86efac');
});
it('FE-COMP-MOOD-007: returns fallback colors for unknown tags', () => {
const light = tagColors('random tag', false);
expect(light.bg).toBe('rgba(0,0,0,0.05)');
expect(light.fg).toBe('#374151');
const dark = tagColors('random tag', true);
expect(dark.bg).toBe('rgba(255,255,255,0.07)');
expect(dark.fg).toBe('#a1a1aa');
});
});
@@ -0,0 +1,65 @@
import { Sparkles, Sun, Minus, Moon, CloudRain, CloudSun, Cloud, CloudLightning, Snowflake, Thermometer, ThermometerSnowflake } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
export interface MoodDef {
id: string
label: string
icon: LucideIcon
color: string
cssVar: string
}
export const MOODS: MoodDef[] = [
{ id: 'amazing', label: 'Amazing', icon: Sparkles, color: '#E8654A', cssVar: 'var(--mood-amazing)' },
{ id: 'good', label: 'Good', icon: Sun, color: '#EF9F27', cssVar: 'var(--mood-good)' },
{ id: 'neutral', label: 'Neutral', icon: Minus, color: '#94928C', cssVar: 'var(--mood-neutral)' },
{ id: 'tired', label: 'Tired', icon: Moon, color: '#6B9BD2', cssVar: 'var(--mood-tired)' },
{ id: 'rough', label: 'Rough', icon: CloudRain,color: '#9B8EC4', cssVar: 'var(--mood-rough)' },
]
export const MOOD_DEFAULT_COLOR = '#D4D4D4'
export function getMood(id: string | null | undefined): MoodDef | undefined {
if (!id) return undefined
return MOODS.find(m => m.id === id)
}
export function moodColor(id: string | null | undefined): string {
return getMood(id)?.cssVar || 'var(--journal-faint)'
}
export interface WeatherDef {
id: string
label: string
icon: LucideIcon
}
export const WEATHERS: WeatherDef[] = [
{ id: 'sunny', label: 'Sunny', icon: Sun },
{ id: 'partly', label: 'Partly cloudy', icon: CloudSun },
{ id: 'cloudy', label: 'Cloudy', icon: Cloud },
{ id: 'rainy', label: 'Rainy', icon: CloudRain },
{ id: 'stormy', label: 'Stormy', icon: CloudLightning },
{ id: 'snowy', label: 'Snowy', icon: Snowflake },
{ id: 'hot', label: 'Hot', icon: Thermometer },
{ id: 'cold', label: 'Cold', icon: ThermometerSnowflake },
]
export function getWeather(id: string | null | undefined): WeatherDef | undefined {
if (!id) return undefined
return WEATHERS.find(w => w.id === id)
}
export const TAG_STYLES: Record<string, { bg: string; fg: string; darkBg: string; darkFg: string }> = {
'hidden gem': { bg: '#dcfce7', fg: '#166534', darkBg: 'rgba(22,101,52,0.2)', darkFg: '#86efac' },
'must revisit': { bg: '#dbeafe', fg: '#1e40af', darkBg: 'rgba(30,64,175,0.2)', darkFg: '#93c5fd' },
'best meal': { bg: '#fef3c7', fg: '#92400e', darkBg: 'rgba(146,64,14,0.2)', darkFg: '#fcd34d' },
'tourist trap': { bg: '#fee2e2', fg: '#991b1b', darkBg: 'rgba(153,27,27,0.2)', darkFg: '#fca5a5' },
'disaster': { bg: '#fce4ec', fg: '#880e4f', darkBg: 'rgba(136,14,79,0.2)', darkFg: '#f48fb1' },
}
export function tagColors(tag: string, dark: boolean) {
const known = TAG_STYLES[tag.toLowerCase()]
if (known) return { bg: dark ? known.darkBg : known.bg, fg: dark ? known.darkFg : known.fg }
return { bg: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)', fg: dark ? '#a1a1aa' : '#374151' }
}
@@ -0,0 +1,38 @@
// FE-UTIL-STRIPMD-001 to FE-UTIL-STRIPMD-006
import { describe, it, expect } from 'vitest';
import { stripMarkdown } from './stripMarkdown';
describe('stripMarkdown', () => {
it('FE-UTIL-STRIPMD-001: strips bold and italic formatting', () => {
expect(stripMarkdown('**bold** and _italic_')).toBe('bold and italic');
expect(stripMarkdown('__also bold__ and *also italic*')).toBe('also bold and also italic');
});
it('FE-UTIL-STRIPMD-002: strips headings', () => {
expect(stripMarkdown('# Heading 1')).toBe('Heading 1');
expect(stripMarkdown('## Heading 2')).toBe('Heading 2');
expect(stripMarkdown('### Heading 3')).toBe('Heading 3');
});
it('FE-UTIL-STRIPMD-003: converts links to text and removes images', () => {
expect(stripMarkdown('[click here](https://example.com)')).toBe('click here');
expect(stripMarkdown('![alt text](image.jpg)')).toBe('');
});
it('FE-UTIL-STRIPMD-004: strips code blocks and inline code', () => {
expect(stripMarkdown('use `console.log`')).toBe('use console.log');
expect(stripMarkdown('```\ncode block\n```')).toBe('');
});
it('FE-UTIL-STRIPMD-005: strips blockquotes and lists', () => {
expect(stripMarkdown('> quoted text')).toBe('quoted text');
expect(stripMarkdown('- item one')).toBe('item one');
expect(stripMarkdown('1. first item')).toBe('first item');
});
it('FE-UTIL-STRIPMD-006: strips strikethrough and horizontal rules', () => {
expect(stripMarkdown('~~deleted~~')).toBe('deleted');
expect(stripMarkdown('---')).toBe('');
});
});
@@ -0,0 +1,24 @@
/**
* Strip markdown formatting to get plain text for previews.
* Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr.
*/
export function stripMarkdown(md: string): string {
return md
.replace(/^#{1,6}\s+/gm, '') // headings
.replace(/!\[.*?\]\(.*?\)/g, '') // images
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
.replace(/`([^`]+)`/g, '$1') // inline code
.replace(/\*\*(.+?)\*\*/g, '$1') // bold **
.replace(/__(.+?)__/g, '$1') // bold __
.replace(/\*(.+?)\*/g, '$1') // italic *
.replace(/_(.+?)_/g, '$1') // italic _
.replace(/~~(.+?)~~/g, '$1') // strikethrough
.replace(/^>\s?/gm, '') // blockquotes
.replace(/^[-*+]\s+/gm, '') // unordered lists
.replace(/^\d+\.\s+/gm, '') // ordered lists
.replace(/^---+$/gm, '') // horizontal rules
.replace(/\n{2,}/g, ' ') // collapse multiple newlines
.replace(/\n/g, ' ') // remaining newlines → spaces
.trim()
}
@@ -0,0 +1,101 @@
// 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(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
});
describe('BottomNav', () => {
it('FE-COMP-BOTTOMNAV-001: renders without crashing', () => {
render(<BottomNav />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />);
expect(screen.getByText('Trips')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
render(<BottomNav />);
expect(screen.getByText('Profile')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
// Profile sheet shows username
expect(screen.getByText('testuser')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('testuser')).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Logout')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => {
const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' });
seedStore(useAuthStore, { user: adminUser, isAuthenticated: true });
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
// Sheet is open — username visible
expect(screen.getByText('testuser')).toBeInTheDocument();
// The outermost fixed div is the backdrop wrapper, clicking it triggers onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop);
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
});
});
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { useAddonStore } from '../../store/addonStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
{ to: '/trips', label: 'Trips', icon: Plane },
]
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
journey: { to: '/journey', label: 'Journey', icon: Compass },
}
export default function BottomNav() {
const { t } = useTranslation()
const darkMode = useSettingsStore(s => s.settings.dark_mode)
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const addons = useAddonStore(s => s.addons)
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
const [showProfile, setShowProfile] = useState(false)
const items = [...BASE_ITEMS]
for (const addon of globalAddons) {
const nav = ADDON_NAV[addon.id]
if (nav) items.push(nav)
}
return (
<>
<nav
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
style={{
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
background: dark ? 'rgba(9,9,11,0.96)' : 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
{items.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
}`
}
>
<Icon size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{label}</span>
</NavLink>
))}
<button
onClick={() => setShowProfile(true)}
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
>
<User size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
</button>
</nav>
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
</>
)
}
function ProfileSheet({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const handleNav = (path: string) => {
onClose()
navigate(path)
}
const handleLogout = () => {
onClose()
logout()
navigate('/login')
}
return (
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Sheet */}
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={e => e.stopPropagation()}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-2">
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
{/* User info */}
<div className="px-6 pb-4 pt-1">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
{(user?.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
</div>
{user?.role === 'admin' && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
<Shield size={10} /> Admin
</span>
)}
</div>
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Links */}
<div className="py-2 px-2">
<button
onClick={() => handleNav('/settings')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Settings size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
</button>
{user?.role === 'admin' && (
<button
onClick={() => handleNav('/admin')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Shield size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
</button>
)}
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Logout */}
<div className="py-2 px-2">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
>
<LogOut size={18} className="text-red-500" />
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
</button>
</div>
<div className="h-4" />
</div>
</div>
)
}
@@ -0,0 +1,32 @@
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import MobileTopHeader from './MobileTopHeader';
describe('MobileTopHeader', () => {
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
render(<MobileTopHeader title="Journeys" />);
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading.textContent).toBe('Journeys');
});
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
expect(screen.getByText('3 trips')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
const { container } = render(<MobileTopHeader title="Journeys" />);
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
expect(subtitleEl).not.toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
render(
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
);
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});
});
@@ -0,0 +1,17 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
}
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
return (
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
<div className="flex-1 min-w-0">
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
</div>
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
</div>
)
}
+1 -1
View File
@@ -16,7 +16,7 @@ beforeEach(() => {
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })), http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })), http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
); );
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true }); seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
seedStore(useSettingsStore, { settings: buildSettings() }); seedStore(useSettingsStore, { settings: buildSettings() });
}); });
+15 -11
View File
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react' import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx' import InAppNotificationBell from './InAppNotificationBell.tsx'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe } const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
interface NavbarProps { interface NavbarProps {
tripTitle?: string tripTitle?: string
@@ -27,14 +27,13 @@ interface Addon {
} }
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement { export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout } = useAuthStore() const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore() const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore() const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false) const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const darkMode = settings.dark_mode const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -45,12 +44,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
if (user) loadAddons() if (user) loadAddons()
}, [user, location.pathname]) }, [user, location.pathname])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
})
}, [])
const handleLogout = () => { const handleLogout = () => {
logout() logout()
navigate('/login', { state: { noRedirect: true } }) navigate('/login', { state: { noRedirect: true } })
@@ -75,7 +68,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
touchAction: 'manipulation', touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)', paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)', height: 'var(--nav-h)',
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]"> }} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */} {/* Left side */}
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
{showBack && ( {showBack && (
@@ -155,6 +148,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button> </button>
)} )}
{/* Prerelease badge */}
{isPrerelease && appVersion && (
<span
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
{appVersion}
</span>
)}
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */} {/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')} <button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex" className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
@@ -714,6 +714,23 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* Disconnected banner — shown when photos exist but provider is unreachable */}
{!connected && allVisible.length > 0 && enabledProviders.length > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', flexShrink: 0,
background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.25)',
fontSize: 12, color: 'var(--text-muted)',
}}>
<Camera size={13} style={{ color: '#ca8a04', flexShrink: 0 }} />
<span>
{t('memories.providerDisconnectedBanner', {
provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ')
})}
</span>
</div>
)}
{/* Header */} {/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}> <div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -1,4 +1,4 @@
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-010 // FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
import { render, screen, waitFor } from '../../../tests/helpers/render'; import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
@@ -99,4 +99,109 @@ describe('InAppNotificationItem', () => {
// Recent notification shows "just now" // Recent notification shows "just now"
expect(screen.getByText('just now')).toBeInTheDocument(); expect(screen.getByText('just now')).toBeInTheDocument();
}); });
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
render(
<InAppNotificationItem
notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })}
/>
);
expect(document.querySelector('img')).toBeInTheDocument();
expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/avatar.png');
});
it('FE-COMP-NOTIF-012: boolean notification shows Accept and Reject buttons', () => {
render(
<InAppNotificationItem
notification={buildNotification({
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
})}
/>
);
expect(screen.getByText('Yes')).toBeInTheDocument();
expect(screen.getByText('No')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-013: clicking Accept calls respondToBoolean with positive', async () => {
const user = userEvent.setup();
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { respondToBoolean });
render(
<InAppNotificationItem
notification={buildNotification({
id: 55,
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
response: null,
})}
/>
);
await user.click(screen.getByText('Yes'));
expect(respondToBoolean).toHaveBeenCalledWith(55, 'positive');
});
it('FE-COMP-NOTIF-014: clicking Reject calls respondToBoolean with negative', async () => {
const user = userEvent.setup();
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { respondToBoolean });
render(
<InAppNotificationItem
notification={buildNotification({
id: 66,
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
response: null,
})}
/>
);
await user.click(screen.getByText('No'));
expect(respondToBoolean).toHaveBeenCalledWith(66, 'negative');
});
it('FE-COMP-NOTIF-015: navigate notification shows action button', () => {
render(
<InAppNotificationItem
notification={buildNotification({
type: 'navigate',
navigate_text_key: 'notifications.title',
navigate_target: '/trips/1',
})}
/>
);
// t('notifications.title') = "Notifications" — the navigate button renders this
const navigateBtn = document.querySelector('button[style*="pointer"]') ??
Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('Notifications'));
expect(navigateBtn).toBeInTheDocument();
});
it('FE-COMP-NOTIF-016: clicking navigate button marks read and navigates', async () => {
const user = userEvent.setup();
const markRead = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
seedStore(useInAppNotificationStore, { markRead });
render(
<InAppNotificationItem
notification={buildNotification({
id: 77,
type: 'navigate',
navigate_text_key: 'notifications.title',
navigate_target: '/trips/1',
is_read: 0,
})}
onClose={onClose}
/>
);
// The navigate button renders t('notifications.title') = "Notifications"
const btn = Array.from(document.querySelectorAll('button')).find(
b => b.textContent?.includes('Notifications')
);
expect(btn).toBeTruthy();
await user.click(btn!);
expect(markRead).toHaveBeenCalledWith(77);
expect(onClose).toHaveBeenCalled();
});
}); });
@@ -0,0 +1,119 @@
// FE-COMP-SCOPE-001 to FE-COMP-SCOPE-009
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { resetAllStores } from '../../../tests/helpers/store';
import ScopeGroupPicker from './ScopeGroupPicker';
beforeEach(() => {
resetAllStores();
});
describe('ScopeGroupPicker', () => {
it('FE-COMP-SCOPE-001: renders scope groups', () => {
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// Several group headers should be visible
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-002: shows Select All button when nothing selected', () => {
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
});
it('FE-COMP-SCOPE-003: Select All calls onChange with all scopes', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /select all/i }));
expect(onChange).toHaveBeenCalledTimes(1);
const called = onChange.mock.calls[0][0] as string[];
expect(called.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-004: shows Deselect All button when all selected', async () => {
// First collect all scopes by clicking Select All and capturing the callback
const user = userEvent.setup();
const captured: string[][] = [];
const { rerender } = render(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
// Now rerender with all scopes selected
rerender(<ScopeGroupPicker selected={allScopes} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /deselect all/i })).toBeInTheDocument();
});
it('FE-COMP-SCOPE-005: Deselect All calls onChange with empty array', async () => {
const user = userEvent.setup();
const captured: string[][] = [];
// Get all scopes first
const { rerender } = render(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
const onChange = vi.fn();
rerender(<ScopeGroupPicker selected={allScopes} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /deselect all/i }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('FE-COMP-SCOPE-006: expanding a group reveals individual scope checkboxes', async () => {
const user = userEvent.setup();
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// Groups are collapsed by default — checkboxes for individual scopes not visible
const groupToggles = screen.getAllByRole('button').filter(b =>
!b.textContent?.toLowerCase().includes('select all') &&
!b.textContent?.toLowerCase().includes('deselect all')
);
// Click the first group expand button
await user.click(groupToggles[0]);
// Individual scope checkboxes should now appear (more than just group-level ones)
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-007: group checkbox selects all scopes in the group', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
const groupCheckboxes = screen.getAllByRole('checkbox');
await user.click(groupCheckboxes[0]);
expect(onChange).toHaveBeenCalledTimes(1);
const called = onChange.mock.calls[0][0] as string[];
expect(called.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-008: individual scope toggle adds/removes that scope', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
// Expand first group
const groupToggles = screen.getAllByRole('button').filter(b =>
!b.textContent?.toLowerCase().includes('select all') &&
!b.textContent?.toLowerCase().includes('deselect all')
);
await user.click(groupToggles[0]);
// There are now individual scope checkboxes — click the second one (first is group-level)
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[1]); // individual scope
expect(onChange).toHaveBeenCalledTimes(1);
});
it('FE-COMP-SCOPE-009: count badge shown when some scopes selected in group', () => {
// Get any single scope key from the first group via Select All trick + manual slice
// We'll just select a scope by triggering group checkbox and passing it in
const firstGroupScope = 'trips:read'; // known scope from SCOPE_GROUPS
render(<ScopeGroupPicker selected={[firstGroupScope]} onChange={vi.fn()} />);
// Count badge like "(1/N)" should be visible
expect(screen.getByText(/\(\d+\/\d+\)/)).toBeInTheDocument();
});
});
@@ -0,0 +1,96 @@
import React, { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { getScopesByGroup } from '../../api/oauthScopes'
import { useTranslation } from '../../i18n'
interface Props {
selected: string[]
onChange: (scopes: string[]) => void
}
export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
const { t } = useTranslation()
const [open, setOpen] = useState<Record<string, boolean>>({})
const scopesByGroup = getScopesByGroup(t)
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
const allSelected = allScopeKeys.every(s => selected.includes(s))
return (
<div className="space-y-1">
<div className="flex justify-end mb-2">
<button
type="button"
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
</button>
</div>
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
const groupScopeKeys = groupScopes.map(s => s.scope)
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
return (
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
<button
type="button"
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
style={{ color: 'var(--text-secondary)' }}>
{open[group]
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 flex-shrink-0" />}
{group}
{someGroupSelected && (
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
</span>
)}
</button>
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
onChange={e => onChange(
e.target.checked
? [...new Set([...selected, ...groupScopeKeys])]
: selected.filter(s => !groupScopeKeys.includes(s))
)}
className="rounded"
title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
/>
</div>
{open[group] && (
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
{groupScopes.map(({ scope, label, description }) => (
<label
key={scope}
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<input
type="checkbox"
checked={selected.includes(scope)}
onChange={e => onChange(
e.target.checked
? [...selected, scope]
: selected.filter(s => s !== scope)
)}
className="mt-0.5 rounded flex-shrink-0"
/>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
</div>
</label>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,147 @@
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
//
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
// that opens a new browser window and writes a full HTML document into it.
// It does NOT render a React component. Tests verify window.open behaviour.
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// Mock `marked` so we don't need the real markdown parser
vi.mock('marked', () => ({
marked: {
parse: (str: string) => `<p>${str}</p>`,
},
}));
import { downloadJourneyBookPDF } from './JourneyBookPDF';
import type { JourneyDetail } from '../../store/journeyStore';
// ── Helpers ──────────────────────────────────────────────────────────────────
function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
return {
id: 1,
user_id: 1,
title: 'Iceland Ring Road',
subtitle: 'Two weeks around the island',
status: 'active',
cover_image: null,
cover_gradient: null,
created_at: Date.now(),
updated_at: Date.now(),
entries: [
{
id: 10,
journey_id: 1,
author_id: 1,
type: 'entry',
title: 'Golden Circle',
story: 'An incredible day of geysers and waterfalls.',
entry_date: '2026-07-01',
entry_time: '09:00',
location_name: 'Thingvellir',
location_lat: 64.255,
location_lng: -21.13,
mood: 'excited',
weather: 'sunny',
tags: [],
pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] },
visibility: 'private',
sort_order: 0,
created_at: Date.now(),
updated_at: Date.now(),
source_trip_id: null,
source_place_id: null,
source_trip_name: null,
photos: [
{
id: 100,
entry_id: 10,
provider: 'local',
file_path: 'journey/geyser.jpg',
thumbnail_path: null,
asset_id: null,
owner_id: null,
shared: 0,
caption: 'Strokkur erupting',
sort_order: 0,
created_at: Date.now(),
},
],
},
],
trips: [],
contributors: [],
stats: { entries: 1, photos: 1, cities: 1 },
...overrides,
} as unknown as JourneyDetail;
}
// ── Mock window.open ─────────────────────────────────────────────────────────
let mockWindow: {
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
focus: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockWindow = {
document: { write: vi.fn(), close: vi.fn() },
focus: vi.fn(),
};
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('downloadJourneyBookPDF', () => {
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(window.open).toHaveBeenCalledWith('', '_blank');
});
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
});
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(html).toContain('Iceland Ring Road');
});
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(html).toContain('Golden Circle');
// Story text is rendered via markdown
expect(html).toContain('An incredible day of geysers and waterfalls.');
// Pros/cons verdict cards are included
expect(html).toContain('Amazing views');
expect(html).toContain('Crowded');
});
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
const journey = buildJourney({ entries: [] });
await downloadJourneyBookPDF(journey);
expect(window.open).toHaveBeenCalled();
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(html).toContain('Iceland Ring Road');
// No entry pages, but cover and closing page are still present
expect(html).toContain('Journey Book');
expect(html).toContain('The End');
});
});
@@ -0,0 +1,307 @@
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { marked } from 'marked'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
function esc(str: string | null | undefined): string {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function md(str: string | null | undefined): string {
if (!str) return ''
return marked.parse(str, { async: false, breaks: true }) as string
}
function abs(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
}
function pSrc(p: JourneyPhoto): string {
if (p.provider === 'local') return abs(`/uploads/${p.file_path}`)
return abs(`/api/integrations/memories/${p.provider}/assets/0/${p.asset_id}/${p.owner_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()
}
@@ -467,6 +467,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
const [showAddItem, setShowAddItem] = useState(false) const [showAddItem, setShowAddItem] = useState(false)
const [newItemName, setNewItemName] = useState('') const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null) const addItemRef = useRef<HTMLInputElement>(null)
const menuBtnRef = useRef<HTMLButtonElement>(null)
const assigneeDropdownRef = useRef<HTMLDivElement>(null) const assigneeDropdownRef = useRef<HTMLDivElement>(null)
const { togglePackingItem } = useTripStore() const { togglePackingItem } = useTripStore()
const toast = useToast() const toast = useToast()
@@ -629,22 +630,27 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
</span> </span>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }} <button ref={menuBtnRef} onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}> onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<MoreHorizontal size={15} /> <MoreHorizontal size={15} />
</button> </button>
{showMenu && ( {showMenu && (() => {
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }} const rect = menuBtnRef.current?.getBoundingClientRect();
onMouseLeave={() => setShowMenu(false)}> return (
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />} <>
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} /> <div style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowMenu(false)} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} /> <div style={{ position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 4 : 0, zIndex: 100, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}>
{canEdit && <> {canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} /> <MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} /> <MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
</>} {canEdit && <>
</div> <div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
)} <MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</>}
</div>
</>
);
})()}
</div> </div>
</div> </div>
@@ -149,7 +149,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
value={caption} value={caption}
onChange={e => setCaption(e.target.value)} onChange={e => setCaption(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()} onKeyDown={e => e.key === 'Enter' && handleSaveCaption()}
placeholder="Beschriftung hinzufügen..." placeholder={t('photos.addCaption')}
className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40" className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40"
autoFocus autoFocus
/> />
@@ -173,7 +173,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
className="text-white text-sm flex-1 cursor-pointer hover:text-white/80" className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
onClick={() => setEditCaption(true)} onClick={() => setEditCaption(true)}
> >
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>} {photo.caption || <span className="text-white/40 italic">{t('photos.addCaption')}</span>}
</p> </p>
<button <button
onClick={() => setEditCaption(true)} onClick={() => setEditCaption(true)}
@@ -43,15 +43,15 @@ describe('PhotoUpload', () => {
it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => { it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
expect(screen.getByText('Fotos hier ablegen')).toBeInTheDocument() expect(screen.getByText('Drop photos here')).toBeInTheDocument()
// Upload icon rendered via lucide-react as SVG // Upload icon rendered via lucide-react as SVG
expect(document.querySelector('svg')).toBeTruthy() expect(document.querySelector('svg')).toBeTruthy()
}) })
it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => { it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
expect(screen.queryByText('Tag verknüpfen')).not.toBeInTheDocument() expect(screen.queryByText('Link Day')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Optionale Beschriftung...')).not.toBeInTheDocument() expect(screen.queryByPlaceholderText('Optional caption...')).not.toBeInTheDocument()
}) })
it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => { it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => {
@@ -65,27 +65,27 @@ describe('PhotoUpload', () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()]) await uploadFiles([makeFile()])
expect(screen.getByAltText('photo.jpg')).toBeInTheDocument() expect(screen.getByAltText('photo.jpg')).toBeInTheDocument()
expect(screen.getByText('Tag verknüpfen')).toBeInTheDocument() expect(screen.getByText('Link Day')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Optionale Beschriftung...')).toBeInTheDocument() expect(screen.getByPlaceholderText('Optional caption...')).toBeInTheDocument()
}) })
it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => { it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')]) await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
expect(screen.getByText('2 Fotos ausgewählt')).toBeInTheDocument() expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
}) })
it('FE-COMP-PHOTOUPLOAD-006: remove button removes a file from preview', async () => { it('FE-COMP-PHOTOUPLOAD-006: remove button removes a file from preview', async () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')]) await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
expect(screen.getByText('2 Fotos ausgewählt')).toBeInTheDocument() expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
// Remove buttons are inside `.relative.aspect-square` wrappers in the preview grid // Remove buttons are inside `.relative.aspect-square` wrappers in the preview grid
const removeButtons = document.querySelectorAll('.relative.aspect-square button') const removeButtons = document.querySelectorAll('.relative.aspect-square button')
expect(removeButtons.length).toBe(2) expect(removeButtons.length).toBe(2)
await userEvent.click(removeButtons[0]) await userEvent.click(removeButtons[0])
expect(screen.getByText('1 Foto ausgewählt')).toBeInTheDocument() expect(screen.getByText('1 Photo selected')).toBeInTheDocument()
expect(screen.getAllByRole('img').length).toBe(1) expect(screen.getAllByRole('img').length).toBe(1)
}) })
@@ -120,7 +120,7 @@ describe('PhotoUpload', () => {
render(<PhotoUpload {...defaultProps} />) render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()]) await uploadFiles([makeFile()])
await userEvent.type(screen.getByPlaceholderText('Optionale Beschriftung...'), 'Vacation') await userEvent.type(screen.getByPlaceholderText('Optional caption...'), 'Vacation')
await userEvent.click(getSubmitButton()) await userEvent.click(getSubmitButton())
@@ -146,7 +146,7 @@ describe('PhotoUpload', () => {
await userEvent.click(getSubmitButton()) await userEvent.click(getSubmitButton())
await waitFor(() => { await waitFor(() => {
expect(screen.getByText(/wird hochgeladen/i)).toBeInTheDocument() expect(screen.getAllByText(/uploading/i).length).toBeGreaterThan(0)
}) })
expect(getSubmitButton()).toBeDisabled() expect(getSubmitButton()).toBeDisabled()
+10 -10
View File
@@ -85,12 +85,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
<input {...getInputProps()} /> <input {...getInputProps()} />
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} /> <Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
{isDragActive ? ( {isDragActive ? (
<p className="text-slate-700 font-medium">Fotos hier ablegen...</p> <p className="text-slate-700 font-medium">{t('photos.dropHere')}</p>
) : ( ) : (
<> <>
<p className="text-gray-600 font-medium">Fotos hier ablegen</p> <p className="text-gray-600 font-medium">{t('photos.dropHereActive')}</p>
<p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p> <p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p>
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p> <p className="text-gray-400 text-xs mt-2">{t('photos.fileTypeHint')}</p>
</> </>
)} )}
</div> </div>
@@ -98,7 +98,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
{/* Preview grid */} {/* Preview grid */}
{files.length > 0 && ( {files.length > 0 && (
<div> <div>
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} Foto{files.length !== 1 ? 's' : ''} ausgewählt</p> <p className="text-sm font-medium text-gray-700 mb-2">{files.length} {t(files.length !== 1 ? 'photos.photosSelected' : 'photos.photoSelected')}</p>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto"> <div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto">
{files.map((file, idx) => ( {files.map((file, idx) => (
<div key={idx} className="relative aspect-square group"> <div key={idx} className="relative aspect-square group">
@@ -126,15 +126,15 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
{files.length > 0 && ( {files.length > 0 && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1">Tag verknüpfen</label> <label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkDay')}</label>
<select <select
value={dayId} value={dayId}
onChange={e => setDayId(e.target.value)} onChange={e => setDayId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
> >
<option value="">Kein Tag</option> <option value="">{t('photos.noDay')}</option>
{(days || []).map(day => ( {(days || []).map(day => (
<option key={day.id} value={day.id}>Tag {day.day_number}</option> <option key={day.id} value={day.id}>{t('photos.dayLabel', { number: day.day_number })}</option>
))} ))}
</select> </select>
</div> </div>
@@ -152,12 +152,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
</select> </select>
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-xs font-medium text-gray-700 mb-1">Beschriftung (für alle)</label> <label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.captionForAll')}</label>
<input <input
type="text" type="text"
value={caption} value={caption}
onChange={e => setCaption(e.target.value)} onChange={e => setCaption(e.target.value)}
placeholder="Optionale Beschriftung..." placeholder={t('photos.captionPlaceholder')}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900" className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/> />
</div> </div>
@@ -169,7 +169,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
<div className="bg-slate-50 rounded-lg p-3"> <div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" /> <div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-slate-900">Wird hochgeladen...</span> <span className="text-sm text-slate-900">{t('common.uploading')}</span>
</div> </div>
<div className="w-full bg-slate-200 rounded-full h-1.5"> <div className="w-full bg-slate-200 rounded-full h-1.5">
<div <div
@@ -167,7 +167,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
<div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}> <div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{ <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)', backdropFilter: 'blur(40px) saturate(180%)',
@@ -189,7 +189,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
</div> </div>
{!collapsed && formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>} {!collapsed && formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>}
</div> </div>
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? 'Expand' : 'Collapse'} <button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, transition: 'all 0.15s ease' }} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, transition: 'all 0.15s ease' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}> onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
+125 -19
View File
@@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X } from 'lucide-react'
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
import { assignmentsApi, reservationsApi } from '../../api/client' import { assignmentsApi, reservationsApi } from '../../api/client'
@@ -55,6 +55,99 @@ const TYPE_ICONS = {
car: '🚗', cruise: '🚢', event: '🎫', other: '📋', car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
} }
function MobileAddPlaceButton({ dayId, places, assignments, onAssign, onAddNew }: {
dayId: number
places: Place[]
assignments: AssignmentsMap
onAssign?: (placeId: number, dayId: number) => void
onAddNew?: () => void
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
// Find places not assigned to this day
const assignedToDay = new Set((assignments[String(dayId)] || []).map(a => a.place_id))
const available = places.filter(p => !assignedToDay.has(p.id))
const filtered = search.trim()
? available.filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
: available
return (
<div className="md:hidden" style={{ padding: '8px 12px 12px' }}>
{!open ? (
<button
onClick={e => { e.stopPropagation(); setOpen(true) }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '10px 0', borderRadius: 12,
border: '1.5px dashed var(--border-primary)',
background: 'transparent', color: 'var(--text-muted)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
}}
>
<Plus size={14} />
Add Place
</button>
) : (
<div style={{ borderRadius: 14, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', overflow: 'hidden' }}>
<div style={{ padding: '8px 10px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 6 }}>
<input
autoFocus
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('dayplan.mobile.searchPlaces')}
style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: 'var(--text-primary)' }}
/>
<button onClick={() => { setOpen(false); setSearch('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}>
<X size={14} />
</button>
</div>
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
{filtered.length === 0 && (
<div style={{ padding: '16px 12px', textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>
{available.length === 0 ? t('dayplan.mobile.allAssigned') : t('dayplan.mobile.noMatch')}
</div>
)}
{filtered.slice(0, 20).map(p => (
<button
key={p.id}
onClick={() => {
onAssign?.(p.id, dayId)
setOpen(false)
setSearch('')
}}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 12px', border: 'none', background: 'transparent',
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
}}
>
<MapPin size={13} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
</button>
))}
</div>
{onAddNew && (
<button
onClick={() => { onAddNew(); setOpen(false); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
padding: '10px 0', borderTop: '1px solid var(--border-faint)',
background: 'transparent', border: 'none', color: 'var(--text-muted)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', cursor: 'pointer',
}}
>
<Plus size={13} />
Create new place
</button>
)}
</div>
)}
</div>
)
}
interface DayPlanSidebarProps { interface DayPlanSidebarProps {
tripId: number tripId: number
trip: Trip trip: Trip
@@ -79,6 +172,8 @@ interface DayPlanSidebarProps {
reservations?: Reservation[] reservations?: Reservation[]
onAddReservation: () => void onAddReservation: () => void
onNavigateToFiles?: () => void onNavigateToFiles?: () => void
onAddPlace?: () => void
onAddPlaceToDay?: (placeId: number, dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
canUndo?: boolean canUndo?: boolean
@@ -95,6 +190,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
reservations = [], reservations = [],
onAddReservation, onAddReservation,
onAddPlace,
onAddPlaceToDay,
onNavigateToFiles, onNavigateToFiles,
onExpandedDaysChange, onExpandedDaysChange,
pushUndo, pushUndo,
@@ -519,7 +616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
}) })
} }
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
} }
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
@@ -606,7 +703,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
tripActions.setAssignments(currentAssignments) tripActions.setAssignments(currentAssignments)
} }
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Unknown error') toast.error(err instanceof Error ? err.message : t('common.unknownError'))
return return
} }
@@ -755,9 +852,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex) await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex)
}) })
}) })
.catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) .catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId && fromDayId !== dayId) { } else if (noteId && fromDayId !== dayId) {
tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} }
setDraggingId(null) setDraggingId(null)
setDropTargetKey(null) setDropTargetKey(null)
@@ -862,7 +959,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
a.download = `${trip?.title || 'trip'}.ics` a.download = `${trip?.title || 'trip'}.ics`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch { toast.error('ICS export failed') } } catch { toast.error(t('planner.icsExportFailed')) }
}} }}
onMouseEnter={() => setIcsHover(true)} onMouseEnter={() => setIcsHover(true)}
onMouseLeave={() => setIcsHover(false)} onMouseLeave={() => setIcsHover(false)}
@@ -1089,11 +1186,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
} else if (assignmentId && fromDayId !== day.id) { } else if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (assignmentId) { } else if (assignmentId) {
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter) handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
} else if (noteId && fromDayId !== day.id) { } else if (noteId && fromDayId !== day.id) {
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId) { } else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter) handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
} }
@@ -1107,11 +1204,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null; return setDropTargetKey(null); window.__dragData = null; return
} }
if (assignmentId && fromDayId !== day.id) { if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
if (noteId && fromDayId !== day.id) { if (noteId && fromDayId !== day.id) {
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
const m = getMergedItems(day.id) const m = getMergedItems(day.id)
@@ -1207,7 +1304,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
setDropTargetKey(null); window.__dragData = null setDropTargetKey(null); window.__dragData = null
} else if (fromAssignmentId && fromDayId !== day.id) { } else if (fromAssignmentId && fromDayId !== day.id) {
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id) const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
@@ -1215,7 +1312,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const tm = getMergedItems(day.id) const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id) const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) { } else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
@@ -1227,7 +1324,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') }, (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true }, { divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])} ])}
@@ -1411,11 +1508,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (placeId) { if (placeId) {
onAssignToDay?.(parseInt(placeId), day.id) onAssignToDay?.(parseInt(placeId), day.id)
} else if (fromAssignmentId && fromDayId !== day.id) { } else if (fromAssignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
} else if (noteId && fromDayId !== day.id) { } else if (noteId && fromDayId !== day.id) {
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
} else if (noteId) { } else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter) handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
} }
@@ -1499,7 +1596,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const tm = getMergedItems(day.id) const tm = getMergedItems(day.id)
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null) setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) { } else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id) handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
@@ -1507,7 +1604,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const tm = getMergedItems(day.id) const tm = getMergedItems(day.id)
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null) setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) { } else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
@@ -1572,11 +1669,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} }
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
if (assignmentId && fromDayId !== day.id) { if (assignmentId && fromDayId !== day.id) {
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
if (noteId && fromDayId !== day.id) { if (noteId && fromDayId !== day.id) {
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
} }
const m = getMergedItems(day.id) const m = getMergedItems(day.id)
@@ -1623,6 +1720,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
</div> </div>
)} )}
{/* Mobile: Add Place from list */}
<MobileAddPlaceButton
dayId={day.id}
places={places}
assignments={assignments}
onAssign={onAssignToDay}
onAddNew={onAddPlace}
/>
</div> </div>
)} )}
</div> </div>
@@ -236,6 +236,7 @@ export default function PlaceFormModal({
onChange={e => setMapsSearch(e.target.value)} onChange={e => setMapsSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())}
placeholder={t('places.mapsSearchPlaceholder')} placeholder={t('places.mapsSearchPlaceholder')}
autoFocus
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" 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 <button
@@ -601,7 +601,7 @@ export default function PlaceInspector({
{selectedDayId && ( {selectedDayId && (
assignmentInDay ? ( assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />} <ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">Remove</span></>} /> label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">{t('inspector.remove')}</span></>} />
) : ( ) : (
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} /> <ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
) )
@@ -611,7 +611,7 @@ export default function PlaceInspector({
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} /> label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)} )}
{!googleDetails?.google_maps_url && place.lat && place.lng && ( {!googleDetails?.google_maps_url && place.lat && place.lng && (
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />} <ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">Google Maps</span>} /> label={<span className="hidden sm:inline">Google Maps</span>} />
)} )}
{(place.website || googleDetails?.website) && ( {(place.website || googleDetails?.website) && (
@@ -5,6 +5,7 @@ import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore'; import { useTripStore } from '../../store/tripStore';
import { usePermissionsStore } from '../../store/permissionsStore'; import { usePermissionsStore } from '../../store/permissionsStore';
import { placesApi } from '../../api/client';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories'; import { buildUser, buildTrip, buildPlace, buildCategory, buildDay, buildAssignment } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server'; import { server } from '../../../tests/helpers/msw/server';
@@ -443,11 +444,8 @@ describe('GPX import', () => {
}); });
it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => { it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => {
server.use( // FormData POST hangs on CI — mock at the API boundary instead of MSW.
http.post('/api/trips/1/places/import/gpx', () => const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
HttpResponse.json({ count: 2, places: [{ id: 10 }, { id: 11 }] })
),
);
const loadTrip = vi.fn().mockResolvedValue(undefined); const loadTrip = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadTrip }); seedStore(useTripStore, { loadTrip });
const addToast = vi.fn(); const addToast = vi.fn();
@@ -465,6 +463,7 @@ describe('GPX import', () => {
undefined, undefined,
); );
}); });
importSpy.mockRestore();
}); });
}); });
@@ -332,7 +332,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) }, canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) }, selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') }, (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
{ divider: true }, { divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])} ])}
@@ -142,7 +142,7 @@ export default function AccountTab(): React.ReactElement {
await updateProfile({ username, email }) await updateProfile({ username, email })
toast.success(t('settings.toast.profileSaved')) toast.success(t('settings.toast.profileSaved'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error') toast.error(err instanceof Error ? err.message : t('common.error'))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -34,7 +34,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
onClick={async () => { onClick={async () => {
try { try {
await updateSetting('dark_mode', opt.value) await updateSetting('dark_mode', opt.value)
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') } } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
@@ -63,7 +63,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={opt.value} key={opt.value}
onClick={async () => { onClick={async () => {
try { await updateSetting('language', opt.value) } try { await updateSetting('language', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -94,7 +94,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
onClick={async () => { onClick={async () => {
setTempUnit(opt.value) setTempUnit(opt.value)
try { await updateSetting('temperature_unit', opt.value) } try { await updateSetting('temperature_unit', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -124,7 +124,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={opt.value} key={opt.value}
onClick={async () => { onClick={async () => {
try { await updateSetting('time_format', opt.value) } try { await updateSetting('time_format', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -154,7 +154,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={String(opt.value)} key={String(opt.value)}
onClick={async () => { onClick={async () => {
try { await updateSetting('route_calculation', opt.value) } try { await updateSetting('route_calculation', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -184,7 +184,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
key={String(opt.value)} key={String(opt.value)}
onClick={async () => { onClick={async () => {
try { await updateSetting('blur_booking_codes', opt.value) } try { await updateSetting('blur_booking_codes', opt.value) }
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') } catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
}} }}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
@@ -1,4 +1,4 @@
// FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-018 // FE-COMP-INTEGRATIONS-001 to FE-COMP-INTEGRATIONS-032
import { render, screen, waitFor } from '../../../tests/helpers/render'; import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
import { useAddonStore } from '../../store/addonStore'; import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories'; import { buildUser } from '../../../tests/helpers/factories';
import { ToastContainer } from '../shared/Toast';
import IntegrationsTab from './IntegrationsTab'; import IntegrationsTab from './IntegrationsTab';
function enableMcp() { function enableMcp() {
@@ -40,6 +41,8 @@ beforeEach(() => {
server.use( server.use(
http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })), http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: [] })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })), http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/oauth/clients', () => HttpResponse.json({ clients: [] })),
http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: [] })),
); );
}); });
@@ -69,18 +72,26 @@ describe('IntegrationsTab', () => {
expect(codeEl!.textContent).toContain('/mcp'); expect(codeEl!.textContent).toContain('/mcp');
}); });
it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered', async () => { it('FE-COMP-INTEGRATIONS-005: JSON config block is rendered when expanded', async () => {
const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
// Config is collapsed by default — no <pre> yet
expect(document.querySelector('pre')).toBeNull();
// Expand by clicking the "Client Configuration" toggle
await user.click(screen.getByRole('button', { name: /Client Configuration/i }));
const preEl = document.querySelector('pre'); const preEl = document.querySelector('pre');
expect(preEl).not.toBeNull(); expect(preEl).not.toBeNull();
expect(preEl!.textContent).toContain('mcpServers'); expect(preEl!.textContent).toContain('mcpServers');
}); });
it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => { it('FE-COMP-INTEGRATIONS-006: "no tokens" message shown when token list is empty', async () => {
const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('No tokens yet. Create one to connect MCP clients.'); await screen.findByText('No tokens yet. Create one to connect MCP clients.');
}); });
@@ -95,8 +106,11 @@ describe('IntegrationsTab', () => {
}), }),
), ),
); );
const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('My Token'); await screen.findByText('My Token');
await screen.findByText('Other Token'); await screen.findByText('Other Token');
}); });
@@ -106,6 +120,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
const createBtn = screen.getByRole('button', { name: /Create New Token/i }); const createBtn = screen.getByRole('button', { name: /Create New Token/i });
await user.click(createBtn); await user.click(createBtn);
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
@@ -116,6 +131,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i })); await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i }); const modalCreateBtn = screen.getByRole('button', { name: /^Create Token$/i });
@@ -127,6 +143,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i })); await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -153,6 +170,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i })); await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -182,6 +200,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i })); await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test'); await user.type(screen.getByPlaceholderText(/Claude Desktop/i), 'test');
@@ -206,6 +225,8 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me'); await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.'); await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.');
@@ -230,6 +251,8 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Delete Me'); await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
// There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal // There are two "Delete Token" buttons: the trash icon (title) and the confirm button in modal
@@ -289,6 +312,8 @@ describe('IntegrationsTab', () => {
const user = userEvent.setup(); const user = userEvent.setup();
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('Cancel Token'); await screen.findByText('Cancel Token');
await user.click(screen.getByTitle('Delete Token')); await user.click(screen.getByTitle('Delete Token'));
await screen.findByRole('button', { name: /^Cancel$/i }); await screen.findByRole('button', { name: /^Cancel$/i });
@@ -319,6 +344,7 @@ describe('IntegrationsTab', () => {
enableMcp(); enableMcp();
render(<IntegrationsTab />); render(<IntegrationsTab />);
await screen.findByText('MCP Configuration'); await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await user.click(screen.getByRole('button', { name: /Create New Token/i })); await user.click(screen.getByRole('button', { name: /Create New Token/i }));
await screen.findByText('Create API Token'); await screen.findByText('Create API Token');
const input = screen.getByPlaceholderText(/Claude Desktop/i); const input = screen.getByPlaceholderText(/Claude Desktop/i);
@@ -328,4 +354,301 @@ describe('IntegrationsTab', () => {
expect(postCalled).toBe(true); expect(postCalled).toBe(true);
}); });
}); });
it('FE-COMP-INTEGRATIONS-019: default tab is OAuth 2.1 Clients — OAuth hint visible, token list hidden', async () => {
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
// OAuth hint is visible on the default tab
expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
// API Tokens "no tokens" message is not rendered
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
});
it('FE-COMP-INTEGRATIONS-020: switching tabs toggles content visibility', async () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
// Default: OAuth hint visible, token list absent
expect(screen.getByText(/Register OAuth 2\.1 clients/i)).toBeInTheDocument();
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
// Switch to API Tokens tab
await user.click(screen.getByRole('button', { name: /API Tokens/i }));
await screen.findByText('No tokens yet. Create one to connect MCP clients.');
expect(screen.queryByText(/Register OAuth 2\.1 clients/i)).toBeNull();
// Switch back to OAuth tab
await user.click(screen.getByRole('button', { name: /OAuth 2\.1 Clients/i }));
await screen.findByText(/Register OAuth 2\.1 clients/i);
expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull();
});
it('FE-COMP-INTEGRATIONS-021: OAuth client list renders when clients exist', async () => {
server.use(
http.get('/api/oauth/clients', () =>
HttpResponse.json({
clients: [
{
id: 'client-1',
client_id: 'clid-abc',
name: 'My OAuth App',
redirect_uris: ['http://localhost'],
allowed_scopes: ['trips:read', 'places:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('My OAuth App');
expect(screen.getByText(/clid-abc/)).toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-022: scope expansion toggle shows more/fewer scopes', async () => {
const user = userEvent.setup();
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
server.use(
http.get('/api/oauth/clients', () =>
HttpResponse.json({
clients: [
{ id: 'c1', client_id: 'cid', name: 'Big App', redirect_uris: ['http://localhost'], allowed_scopes: scopes, created_at: '2025-01-01T00:00:00Z' },
],
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('Big App');
// "+2 more" button visible (7 scopes, 5 shown)
const moreBtn = screen.getByText(/^\+\d+$/);
await user.click(moreBtn);
// Show less / collapse button now visible
expect(screen.getByText('')).toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-023: active OAuth sessions section renders when sessions exist', async () => {
server.use(
http.get('/api/oauth/sessions', () =>
HttpResponse.json({
sessions: [
{
id: 10,
client_name: 'Claude Desktop',
scopes: ['trips:read'],
access_token_expires_at: '2025-12-31T00:00:00Z',
},
],
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('Claude Desktop');
expect(screen.getByText(/trips:read/)).toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-024: Create OAuth Client modal opens and shows presets', async () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
expect(screen.getByText('Claude.ai')).toBeInTheDocument();
expect(screen.getByText('Claude Desktop')).toBeInTheDocument();
});
it('FE-COMP-INTEGRATIONS-025: clicking a preset fills form fields', async () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
// Presets render as buttons — click "Claude.ai" preset
const presetBtns = screen.getAllByRole('button', { name: /Claude\.ai/i });
await user.click(presetBtns[0]);
// Name field should be filled with 'Claude.ai'
const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
expect((nameInput as HTMLInputElement).value).toBe('Claude.ai');
});
it('FE-COMP-INTEGRATIONS-026: creating client shows success view with client_id and secret', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({
client: {
id: 'new-id',
client_id: 'clid-new',
client_secret: 'secret-value',
name: 'Test Client',
redirect_uris: ['http://localhost'],
allowed_scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
const nameInput = screen.getByPlaceholderText(/Claude Web, My MCP App/i);
await user.type(nameInput, 'Test Client');
const uriInput = screen.getByPlaceholderText(/https:\/\/your-app/i);
await user.type(uriInput, 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
// Success view shows client credentials (there may be multiple matches in list + modal)
await screen.findAllByText(/clid-new/);
const secretEls = await screen.findAllByText(/secret-value/);
expect(secretEls.length).toBeGreaterThan(0);
});
it('FE-COMP-INTEGRATIONS-027: Done button closes created-client modal', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({
client: {
id: 'n2',
client_id: 'clid-n2',
client_secret: 'secret-n2',
name: 'TC2',
redirect_uris: ['http://localhost'],
allowed_scopes: ['trips:read'],
created_at: '2025-01-01T00:00:00Z',
},
})
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'TC2');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
await screen.findAllByText(/clid-n2/);
// Check the "Client Registered" modal title is visible before Done
expect(screen.getByText('Client Registered')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Done$/i }));
await waitFor(() => {
expect(screen.queryByText('Client Registered')).toBeNull();
});
});
it('FE-COMP-INTEGRATIONS-028: delete OAuth client confirmation removes client from list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/oauth/clients', () =>
HttpResponse.json({
clients: [
{ id: 'del-1', client_id: 'cid-del', name: 'Delete Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/oauth/clients/del-1', () => HttpResponse.json({ success: true }))
);
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('Delete Me');
await user.click(screen.getByTitle('Delete Client'));
// Confirmation modal
await screen.findByRole('heading', { name: 'Delete Client' });
const confirmBtns = screen.getAllByRole('button', { name: /Delete Client/i });
// Modal confirm button is last in DOM (modal renders after list)
await user.click(confirmBtns[confirmBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Delete Me')).toBeNull();
});
});
it('FE-COMP-INTEGRATIONS-029: rotate secret confirmation shows new secret', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/oauth/clients', () =>
HttpResponse.json({
clients: [
{ id: 'rot-1', client_id: 'cid-rot', name: 'Rotate Me', redirect_uris: ['http://localhost'], allowed_scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.post('/api/oauth/clients/rot-1/rotate', () =>
HttpResponse.json({ client_secret: 'new-rotated-secret' })
)
);
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('Rotate Me');
await user.click(screen.getByTitle('Rotate Secret'));
await screen.findByText('Rotate Secret');
// Confirm — button text is 'Rotate'
const rotateBtns = screen.getAllByRole('button', { name: /^Rotate$/i });
await user.click(rotateBtns[rotateBtns.length - 1]);
await screen.findByText(/new-rotated-secret/);
});
it('FE-COMP-INTEGRATIONS-030: revoke OAuth session removes it from list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/oauth/sessions', () =>
HttpResponse.json({
sessions: [
{ id: 99, client_name: 'Revoke App', scopes: ['trips:read'], access_token_expires_at: '2025-12-31T00:00:00Z' },
],
})
),
http.delete('/api/oauth/sessions/99', () => HttpResponse.json({ success: true }))
);
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('Revoke App');
await user.click(screen.getByText('Revoke'));
// Confirmation modal
await screen.findByText('Revoke Session');
const revokeBtns = screen.getAllByRole('button', { name: /^Revoke$/i });
// Modal confirm button is last in DOM
await user.click(revokeBtns[revokeBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Revoke App')).toBeNull();
});
});
it('FE-COMP-INTEGRATIONS-031: Register Client button disabled when name or URI is empty', async () => {
const user = userEvent.setup();
enableMcp();
render(<IntegrationsTab />);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
const createBtn = screen.getByRole('button', { name: /Register Client/i });
expect(createBtn).toBeDisabled();
// Type only name, not URI → still disabled
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Test');
expect(createBtn).toBeDisabled();
});
it('FE-COMP-INTEGRATIONS-032: error toast shown when create OAuth client fails', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/oauth/clients', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
);
enableMcp();
render(<><ToastContainer /><IntegrationsTab /></>);
await screen.findByText('MCP Configuration');
await user.click(screen.getByRole('button', { name: /New Client/i }));
await screen.findByText('Register OAuth Client');
await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/i), 'Fail Client');
await user.type(screen.getByPlaceholderText(/https:\/\/your-app/i), 'http://localhost');
await user.click(screen.getByRole('button', { name: /Register Client/i }));
await screen.findByText(/Failed to register/i);
});
}); });
@@ -1,12 +1,87 @@
import Section from './Section' import Section from './Section'
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react' import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react'
import { authApi } from '../../api/client' import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection' import PhotoProvidersSection from './PhotoProvidersSection'
import { ALL_SCOPES } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
interface OAuthPreset {
id: string
label: string
name: string
uris: string
scopes: string[]
}
const OAUTH_PRESETS: OAuthPreset[] = [
{
id: 'claude-web',
label: 'Claude.ai',
name: 'Claude.ai',
uris: 'https://claude.ai/api/mcp/auth_callback',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
{
id: 'claude-desktop',
label: 'Claude Desktop',
name: 'Claude Desktop',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
{
id: 'cursor',
label: 'Cursor',
name: 'Cursor',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
{
id: 'vscode',
label: 'VS Code',
name: 'VS Code / Copilot',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => s.endsWith(':read')),
},
{
id: 'windsurf',
label: 'Windsurf',
name: 'Windsurf',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
{
id: 'zed',
label: 'Zed',
name: 'Zed',
uris: 'http://localhost',
scopes: ALL_SCOPES.filter(s => !s.includes(':delete')),
},
]
interface OAuthClient {
id: string
name: string
client_id: string
redirect_uris: string[]
allowed_scopes: string[]
created_at: string
client_secret?: string // only present on create
}
interface OAuthSession {
id: number
client_id: string
client_name: string
scopes: string[]
access_token_expires_at: string
refresh_token_expires_at: string
created_at: string
}
interface McpToken { interface McpToken {
id: number id: number
@@ -26,6 +101,28 @@ export default function IntegrationsTab(): React.ReactElement {
loadAddons() loadAddons()
}, [loadAddons]) }, [loadAddons])
// OAuth clients state
const [oauthClients, setOauthClients] = useState<OAuthClient[]>([])
const [oauthSessions, setOauthSessions] = useState<OAuthSession[]>([])
const [oauthCreateOpen, setOauthCreateOpen] = useState(false)
const [oauthNewName, setOauthNewName] = useState('')
const [oauthNewUris, setOauthNewUris] = useState('')
const [oauthNewScopes, setOauthNewScopes] = useState<string[]>([])
const [oauthCreating, setOauthCreating] = useState(false)
const [oauthCreatedClient, setOauthCreatedClient] = useState<OAuthClient | null>(null)
const [oauthDeleteId, setOauthDeleteId] = useState<string | null>(null)
const [oauthRevokeId, setOauthRevokeId] = useState<number | null>(null)
const [oauthRotateId, setOauthRotateId] = useState<string | null>(null)
const [oauthRotatedSecret, setOauthRotatedSecret] = useState<string | null>(null)
const [oauthRotating, setOauthRotating] = useState(false)
// oauthScopesOpen is managed internally by ScopeGroupPicker
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
// MCP sub-tab state
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
const [configOpenOAuth, setConfigOpenOAuth] = useState(false)
const [configOpenToken, setConfigOpenToken] = useState(false)
// MCP state // MCP state
const [mcpTokens, setMcpTokens] = useState<McpToken[]>([]) const [mcpTokens, setMcpTokens] = useState<McpToken[]>([])
const [mcpModalOpen, setMcpModalOpen] = useState(false) const [mcpModalOpen, setMcpModalOpen] = useState(false)
@@ -34,8 +131,26 @@ export default function IntegrationsTab(): React.ReactElement {
const [mcpCreating, setMcpCreating] = useState(false) const [mcpCreating, setMcpCreating] = useState(false)
const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null) const [mcpDeleteId, setMcpDeleteId] = useState<number | null>(null)
const [copiedKey, setCopiedKey] = useState<string | null>(null) const [copiedKey, setCopiedKey] = useState<string | null>(null)
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
}, [])
const mcpEndpoint = `${window.location.origin}/mcp` const mcpEndpoint = `${window.location.origin}/mcp`
const mcpJsonConfigOAuth = `{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"${mcpEndpoint}",
"--static-oauth-client-info",
"{\\"client_id\\": \\"<your_client_id>\\", \\"client_secret\\": \\"<your_client_secret>\\"}"
]
}
}
}`
const mcpJsonConfig = `{ const mcpJsonConfig = `{
"mcpServers": { "mcpServers": {
"trek": { "trek": {
@@ -85,10 +200,72 @@ export default function IntegrationsTab(): React.ReactElement {
const handleCopy = (text: string, key: string) => { const handleCopy = (text: string, key: string) => {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
setCopiedKey(key) setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000) if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000)
}) })
} }
// Load OAuth clients and sessions
useEffect(() => {
if (mcpEnabled) {
oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {})
oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {})
}
}, [mcpEnabled])
const handleCreateOAuthClient = async () => {
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
setOauthCreating(true)
try {
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
setOauthCreatedClient(d.client)
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
setOauthNewName('')
setOauthNewUris('')
setOauthNewScopes([])
} catch {
toast.error(t('settings.oauth.toast.createError'))
} finally {
setOauthCreating(false)
}
}
const handleDeleteOAuthClient = async (id: string) => {
try {
await oauthApi.clients.delete(id)
setOauthClients(prev => prev.filter(c => c.id !== id))
setOauthDeleteId(null)
toast.success(t('settings.oauth.toast.deleted'))
} catch {
toast.error(t('settings.oauth.toast.deleteError'))
}
}
const handleRotateSecret = async (id: string) => {
setOauthRotating(true)
try {
const d = await oauthApi.clients.rotate(id)
setOauthRotatedSecret(d.client_secret)
setOauthRotateId(null)
} catch {
toast.error(t('settings.oauth.toast.rotateError'))
} finally {
setOauthRotating(false)
}
}
const handleRevokeSession = async (id: number) => {
try {
await oauthApi.sessions.revoke(id)
setOauthSessions(prev => prev.filter(s => s.id !== id))
setOauthRevokeId(null)
toast.success(t('settings.oauth.toast.revoked'))
} catch {
toast.error(t('settings.oauth.toast.revokeError'))
}
}
return ( return (
<> <>
<PhotoProvidersSection /> <PhotoProvidersSection />
@@ -109,63 +286,217 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
</div> </div>
{/* JSON config box */} {/* Sub-tab bar */}
<div> <div className="flex gap-1 rounded-lg p-1" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<div className="flex items-center justify-between mb-1.5"> <button
<label className="block text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</label> onClick={() => setActiveMcpTab('oauth')}
<button onClick={() => handleCopy(mcpJsonConfig, 'json')} className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700" activeMcpTab === 'oauth' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}> }`}>
{copiedKey === 'json' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />} {t('settings.oauth.clients')}
{copiedKey === 'json' ? t('settings.mcp.copied') : t('settings.mcp.copy')} </button>
</button> <button
</div> onClick={() => setActiveMcpTab('apitokens')}
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}> className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2 ${
{mcpJsonConfig} activeMcpTab === 'apitokens' ? 'bg-slate-900 text-white' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
</pre> }`}>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p> {t('settings.mcp.apiTokens')}
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium"
style={{ background: 'rgba(245,158,11,0.15)', color: '#b45309', border: '1px solid rgba(245,158,11,0.4)' }}>
Deprecated
</span>
</button>
</div> </div>
{/* Token list */} {/* OAuth 2.1 Clients tab */}
<div> {activeMcpTab === 'oauth' && (
<div className="flex items-center justify-between mb-2"> <>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.apiTokens')}</label> {/* JSON config — OAuth (collapsible) */}
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
style={{ background: 'var(--accent-primary, #4f46e5)', color: '#fff' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button>
</div>
{mcpTokens.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
{t('settings.mcp.noTokens')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> <div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{mcpTokens.map((token, i) => ( <button
<div key={token.id} className="flex items-center gap-3 px-4 py-3" onClick={() => setConfigOpenOAuth(o => !o)}
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}> className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
<div className="flex-1 min-w-0"> style={{ background: 'var(--bg-secondary)' }}>
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p> <span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}> {configOpenOAuth ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
{token.token_prefix}... </button>
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span> {configOpenOAuth && (
{token.last_used_at && ( <div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span> <div className="flex justify-end mb-1.5">
)} <button onClick={() => handleCopy(mcpJsonConfigOAuth, 'json-oauth')}
</p> className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{copiedKey === 'json-oauth' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-oauth' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div> </div>
<button onClick={() => setMcpDeleteId(token.id)} <pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20" {mcpJsonConfigOAuth}
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}> </pre>
<Trash2 className="w-4 h-4" /> <p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHintOAuth')}</p>
</button>
</div> </div>
))} )}
</div> </div>
)}
</div> <div>
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
<div className="flex justify-end mb-2">
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
</button>
</div>
{oauthClients.length === 0 ? (
<p className="text-sm py-3 text-center rounded-lg border" style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)' }}>
{t('settings.oauth.noClients')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{oauthClients.map((client, i) => (
<div key={client.id} className="px-4 py-3"
style={{ borderBottom: i < oauthClients.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex items-center gap-3">
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.clientId')}: {client.client_id}
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
<span key={s} className="px-1.5 py-0.5 rounded text-xs" style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>{s}</span>
))}
{client.allowed_scopes.length > 5 && (
<button
onClick={() => setOauthScopesExpanded(prev => ({ ...prev, [client.id]: !prev[client.id] }))}
className="px-1.5 py-0.5 rounded text-xs transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
{oauthScopesExpanded[client.id] ? '' : `+${client.allowed_scopes.length - 5}`}
</button>
)}
</div>
</div>
<button onClick={() => setOauthRotateId(client.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.rotateSecret')}>
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={() => setOauthDeleteId(client.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.oauth.deleteClient')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Active OAuth Sessions */}
{oauthSessions.length > 0 && (
<div>
<label className="text-sm font-medium block mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.activeSessions')}</label>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{oauthSessions.map((session, i) => (
<div key={session.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < oauthSessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}
<span className="ml-3">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
</p>
</div>
<button onClick={() => setOauthRevokeId(session.id)}
className="px-2.5 py-1 rounded text-xs border transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
{t('settings.oauth.revoke')}
</button>
</div>
))}
</div>
</div>
)}
</>
)}
{/* API Tokens tab (deprecated) */}
{activeMcpTab === 'apitokens' && (
<>
<div className="flex items-baseline gap-2 px-3 py-2.5 rounded-lg" style={{ background: 'rgba(245,158,11,0.06)', border: '1px solid rgba(245,158,11,0.3)' }}>
<span className="text-amber-500 flex-shrink-0 leading-none"></span>
<p className="text-xs" style={{ color: '#92400e' }}>{t('settings.mcp.apiTokensDeprecated')}</p>
</div>
{/* JSON config — API Token (collapsible) */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<button
onClick={() => setConfigOpenToken(o => !o)}
className="w-full flex items-center justify-between px-3 py-2.5 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
style={{ background: 'var(--bg-secondary)' }}>
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.mcp.clientConfig')}</span>
{configOpenToken ? <ChevronDown className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} /> : <ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />}
</button>
{configOpenToken && (
<div className="p-3 border-t" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex justify-end mb-1.5">
<button onClick={() => handleCopy(mcpJsonConfig, 'json-token')}
className="flex items-center gap-1.5 px-2.5 py-1 rounded text-xs border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{copiedKey === 'json-token' ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
{copiedKey === 'json-token' ? t('settings.mcp.copied') : t('settings.mcp.copy')}
</button>
</div>
<pre className="p-3 rounded-lg text-xs font-mono overflow-x-auto border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{mcpJsonConfig}
</pre>
<p className="mt-1.5 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.mcp.clientConfigHint')}</p>
</div>
)}
</div>
<div className="flex justify-end">
<button onClick={() => { setMcpModalOpen(true); setMcpCreatedToken(null); setMcpNewName('') }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors opacity-60"
style={{ background: 'var(--bg-tertiary, #e5e7eb)', color: 'var(--text-secondary)' }}>
<Plus className="w-3.5 h-3.5" /> {t('settings.mcp.createToken')}
</button>
</div>
{mcpTokens.length === 0 ? (
<p className="text-sm py-2 text-center" style={{ color: 'var(--text-tertiary)' }}>
{t('settings.mcp.noTokens')}
</p>
) : (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
{mcpTokens.map((token, i) => (
<div key={token.id} className="flex items-center gap-3 px-4 py-3"
style={{ borderBottom: i < mcpTokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{token.token_prefix}...
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
{token.last_used_at && (
<span className="ml-2">· {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
)}
</p>
</div>
<button onClick={() => setMcpDeleteId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('settings.mcp.deleteTokenTitle')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</>
)}
</Section> </Section>
)} )}
@@ -182,7 +513,7 @@ export default function IntegrationsTab(): React.ReactElement {
<input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)} <input type="text" value={mcpNewName} onChange={e => setMcpNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()} onKeyDown={e => e.key === 'Enter' && handleCreateMcpToken()}
placeholder={t('settings.mcp.modal.tokenNamePlaceholder')} placeholder={t('settings.mcp.modal.tokenNamePlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300" className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus /> autoFocus />
</div> </div>
@@ -192,8 +523,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating} <button onClick={handleCreateMcpToken} disabled={!mcpNewName.trim() || mcpCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')} {mcpCreating ? t('settings.mcp.modal.creating') : t('settings.mcp.modal.create')}
</button> </button>
</div> </div>
@@ -217,8 +547,7 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }} <button onClick={() => { setMcpModalOpen(false); setMcpCreatedToken(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white" className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{t('settings.mcp.modal.done')} {t('settings.mcp.modal.done')}
</button> </button>
</div> </div>
@@ -248,6 +577,216 @@ export default function IntegrationsTab(): React.ReactElement {
</div> </div>
</div> </div>
)} )}
{/* Create OAuth Client modal */}
{oauthCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}>
<div className="rounded-xl shadow-xl w-full max-w-lg p-6 space-y-4 overflow-y-auto max-h-[90vh]" style={{ background: 'var(--bg-card)' }}>
{!oauthCreatedClient ? (
<>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createTitle')}</h3>
<div>
<label className="block text-xs font-medium mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.presets')}</label>
<div className="flex flex-wrap gap-1.5">
{OAUTH_PRESETS.map(preset => (
<button
key={preset.id}
type="button"
onClick={() => {
setOauthNewName(preset.name)
setOauthNewUris(preset.uris)
setOauthNewScopes(preset.scopes)
}}
className="px-2.5 py-1 rounded-md text-xs font-medium border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-secondary)' }}>
{preset.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.clientName')}</label>
<input type="text" value={oauthNewName} onChange={e => setOauthNewName(e.target.value)}
placeholder={t('settings.oauth.modal.clientNamePlaceholder')}
className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}
autoFocus />
</div>
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
rows={3}
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
<p className="text-xs mb-2" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.scopesHint')}</p>
<ScopeGroupPicker selected={oauthNewScopes} onChange={setOauthNewScopes} />
</div>
<div className="flex gap-2 justify-end pt-1">
<button onClick={() => setOauthCreateOpen(false)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleCreateOAuthClient}
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
</button>
</div>
</>
) : (
<>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.modal.createdTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.createdWarning')}</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientId')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthCreatedClient.client_id}
</code>
<button onClick={() => handleCopy(oauthCreatedClient.client_id, 'new-client-id')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)' }}>
{copiedKey === 'new-client-id' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthCreatedClient.client_secret}
</code>
<button onClick={() => handleCopy(oauthCreatedClient.client_secret!, 'new-client-secret')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)' }}>
{copiedKey === 'new-client-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
</div>
<div className="flex justify-end">
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
{t('settings.mcp.modal.done')}
</button>
</div>
</>
)}
</div>
</div>
)}
{/* Delete OAuth Client confirm */}
{oauthDeleteId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthDeleteId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.deleteClient')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.deleteClientMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthDeleteId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleDeleteOAuthClient(oauthDeleteId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('settings.oauth.deleteClient')}
</button>
</div>
</div>
</div>
)}
{/* Rotate OAuth Client Secret confirm */}
{oauthRotateId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthRotateId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecret')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthRotateId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleRotateSecret(oauthRotateId)} disabled={oauthRotating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
{oauthRotating ? t('settings.oauth.rotateSecretConfirming') : t('settings.oauth.rotateSecretConfirm')}
</button>
</div>
</div>
</div>
)}
{/* Rotated Secret display */}
{oauthRotatedSecret !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}>
<div className="rounded-xl shadow-xl w-full max-w-md p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.rotateSecretDoneTitle')}</h3>
<div className="flex items-start gap-2 p-3 rounded-lg border border-amber-200" style={{ background: 'rgba(251,191,36,0.1)' }}>
<span className="text-amber-500 mt-0.5"></span>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.rotateSecretDoneWarning')}</p>
</div>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.clientSecret')}</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 rounded-lg text-xs font-mono border break-all" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-primary)' }}>
{oauthRotatedSecret}
</code>
<button onClick={() => handleCopy(oauthRotatedSecret, 'rotated-secret')}
className="p-2 rounded-lg border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)' }}>
{copiedKey === 'rotated-secret' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" style={{ color: 'var(--text-secondary)' }} />}
</button>
</div>
</div>
<div className="flex justify-end">
<button onClick={() => setOauthRotatedSecret(null)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
{t('settings.mcp.modal.done')}
</button>
</div>
</div>
</div>
)}
{/* Revoke OAuth Session confirm */}
{oauthRevokeId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setOauthRevokeId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('settings.oauth.revokeSession')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.revokeSessionMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setOauthRevokeId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleRevokeSession(oauthRevokeId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('settings.oauth.revoke')}
</button>
</div>
</div>
</div>
)}
</> </>
) )
} }
@@ -74,7 +74,7 @@ export default function MapSettingsTab(): React.ReactElement {
}) })
toast.success(t('settings.toast.mapSaved')) toast.success(t('settings.toast.mapSaved'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Error') toast.error(err instanceof Error ? err.message : t('common.error'))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -35,14 +35,14 @@ describe('NotificationsTab', () => {
http.get('/api/notifications/preferences', () => new Promise(() => {})), http.get('/api/notifications/preferences', () => new Promise(() => {})),
); );
render(<NotificationsTab />); render(<NotificationsTab />);
expect(screen.getByText('Loading')).toBeInTheDocument(); expect(screen.getByText('Loading...')).toBeInTheDocument();
}); });
it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => { it('FE-COMP-NOTIFICATIONS-002: renders the matrix after preferences load', async () => {
render(<NotificationsTab />); render(<NotificationsTab />);
// The event label is translated; fallback is the key itself // The event label is translated; fallback is the key itself
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// Should render a toggle (ToggleSwitch renders a button) // Should render a toggle (ToggleSwitch renders a button)
const toggles = await screen.findAllByRole('button'); const toggles = await screen.findAllByRole('button');
@@ -52,7 +52,7 @@ describe('NotificationsTab', () => {
it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => { it('FE-COMP-NOTIFICATIONS-003: renders channel header labels', async () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// inapp channel header should appear (either translated or raw key) // inapp channel header should appear (either translated or raw key)
const headers = screen.getAllByText(/inapp|in.?app/i); const headers = screen.getAllByText(/inapp|in.?app/i);
@@ -72,7 +72,7 @@ describe('NotificationsTab', () => {
); );
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// Should show noChannels message (translated or key) // Should show noChannels message (translated or key)
const noChannelEl = await screen.findByText(/no.*channel|noChannels/i); const noChannelEl = await screen.findByText(/no.*channel|noChannels/i);
@@ -97,7 +97,7 @@ describe('NotificationsTab', () => {
); );
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// A dash should appear for non-implemented combos // A dash should appear for non-implemented combos
const dashes = await screen.findAllByText('—'); const dashes = await screen.findAllByText('—');
@@ -116,7 +116,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// minimalMatrix has inapp:true and email:false for trip_invite // minimalMatrix has inapp:true and email:false for trip_invite
@@ -144,7 +144,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// Find the inapp toggle for trip_invite — it starts as "on" // Find the inapp toggle for trip_invite — it starts as "on"
@@ -156,8 +156,8 @@ describe('NotificationsTab', () => {
// After the error, the toggle should revert back (still rendered in the DOM) // After the error, the toggle should revert back (still rendered in the DOM)
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
expect(screen.queryByText('Saving')).not.toBeInTheDocument(); expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
}); });
// The toggle should still be present (not removed on error) // The toggle should still be present (not removed on error)
@@ -178,20 +178,20 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const toggleButtons = await screen.findAllByRole('button'); const toggleButtons = await screen.findAllByRole('button');
await user.click(toggleButtons[0]); await user.click(toggleButtons[0]);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Saving')).toBeInTheDocument(); expect(screen.getByText('Saving...')).toBeInTheDocument();
}); });
resolveRequest(); resolveRequest();
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Saving')).not.toBeInTheDocument(); expect(screen.queryByText('Saving...')).not.toBeInTheDocument();
}); });
}); });
@@ -209,7 +209,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
// Webhook URL input should be present // Webhook URL input should be present
@@ -238,7 +238,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const input = await screen.findByRole('textbox'); const input = await screen.findByRole('textbox');
@@ -265,7 +265,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const input = await screen.findByRole('textbox'); const input = await screen.findByRole('textbox');
@@ -297,7 +297,7 @@ describe('NotificationsTab', () => {
render(<NotificationsTab />); render(<NotificationsTab />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
await screen.findByRole('textbox'); await screen.findByRole('textbox');
@@ -330,7 +330,7 @@ describe('NotificationsTab', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const input = await screen.findByRole('textbox'); const input = await screen.findByRole('textbox');
@@ -371,7 +371,7 @@ describe('NotificationsTab', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('Loading')).not.toBeInTheDocument(); expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
}); });
const input = await screen.findByRole('textbox'); const input = await screen.findByRole('textbox');
@@ -107,7 +107,7 @@ export default function NotificationsTab(): React.ReactElement {
} }
const renderContent = () => { const renderContent = () => {
if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>Loading</p> if (!matrix) return <p style={{ fontSize: 12, color: 'var(--text-faint)', fontStyle: 'italic' }}>{t('common.loading')}</p>
if (visibleChannels.length === 0) { if (visibleChannels.length === 0) {
return ( return (
@@ -119,7 +119,7 @@ export default function NotificationsTab(): React.ReactElement {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>Saving</p>} {saving && <p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 8 }}>{t('common.saving')}</p>}
{matrix.available_channels.webhook && ( {matrix.available_channels.webhook && (
<div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}> <div style={{ marginBottom: 16, padding: '12px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}> <label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>
@@ -11,6 +11,7 @@ interface ProviderField {
label: string label: string
input_type: string input_type: string
placeholder?: string | null placeholder?: string | null
hint?: string | null
required: boolean required: boolean
secret: boolean secret: boolean
settings_key?: string | null settings_key?: string | null
@@ -71,6 +72,10 @@ export default function PhotoProvidersSection(): React.ReactElement {
const payload: Record<string, unknown> = {} const payload: Record<string, unknown> = {}
for (const field of getProviderFields(provider)) { for (const field of getProviderFields(provider)) {
const payloadKey = field.payload_key || field.settings_key || field.key const payloadKey = field.payload_key || field.settings_key || field.key
if (field.input_type === 'checkbox') {
payload[payloadKey] = values[field.key] === 'true'
continue
}
const value = (values[field.key] || '').trim() const value = (values[field.key] || '').trim()
if (field.secret && !value) continue if (field.secret && !value) continue
payload[payloadKey] = value payload[payloadKey] = value
@@ -102,6 +107,18 @@ export default function PhotoProvidersSection(): React.ReactElement {
const cfg = getProviderConfig(provider) const cfg = getProviderConfig(provider)
const fields = getProviderFields(provider) const fields = getProviderFields(provider)
// Seed checkbox defaults before the async settings load resolves
const checkboxDefaults: Record<string, string> = {}
for (const field of fields) {
if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false'
}
if (Object.keys(checkboxDefaults).length > 0) {
setProviderValues(prev => ({
...prev,
[provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) },
}))
}
if (cfg.settings_get) { if (cfg.settings_get) {
apiClient.get(cfg.settings_get).then(res => { apiClient.get(cfg.settings_get).then(res => {
if (isCancelled) return if (isCancelled) return
@@ -112,7 +129,13 @@ export default function PhotoProvidersSection(): React.ReactElement {
if (field.secret) continue if (field.secret) continue
const sourceKey = field.settings_key || field.payload_key || field.key const sourceKey = field.settings_key || field.payload_key || field.key
const rawValue = (res.data as Record<string, unknown>)[sourceKey] const rawValue = (res.data as Record<string, unknown>)[sourceKey]
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : rawValue != null ? String(rawValue) : '' if (rawValue != null) {
nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue)
} else if (field.input_type === 'checkbox') {
nextValues[field.key] = 'false'
} else {
nextValues[field.key] = ''
}
} }
setProviderValues(prev => ({ setProviderValues(prev => ({
...prev, ...prev,
@@ -198,14 +221,31 @@ export default function PhotoProvidersSection(): React.ReactElement {
<div className="space-y-3"> <div className="space-y-3">
{fields.map(field => ( {fields.map(field => (
<div key={`${provider.id}-${field.key}`}> <div key={`${provider.id}-${field.key}`}>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label> {field.input_type === 'checkbox' ? (
<input <label className="flex items-center gap-2 cursor-pointer select-none">
type={field.input_type || 'text'} <input
value={values[field.key] || ''} type="checkbox"
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)} checked={values[field.key] === 'true'}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" className="w-4 h-4 rounded border-slate-300 accent-slate-900"
/> />
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</label>
) : (
<>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
<input
type={field.input_type || 'text'}
value={values[field.key] || ''}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)}
placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
/>
{field.hint && (
<p className="mt-1 text-xs text-slate-500">{t(`memories.${field.hint}`)}</p>
)}
</>
)}
</div> </div>
))} ))}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -213,7 +253,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
onClick={() => handleSaveProvider(provider)} onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)} disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400" className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
title={!canSave ? 'Save route is not configured for this provider' : isProviderSaveDisabled(provider) ? 'Please fill all required fields' : ''} title={!canSave ? t('memories.saveRouteNotConfigured') : isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''}
> >
<Save className="w-4 h-4" /> {t('common.save')} <Save className="w-4 h-4" /> {t('common.save')}
</button> </button>
@@ -221,18 +261,23 @@ export default function PhotoProvidersSection(): React.ReactElement {
onClick={() => handleTestProvider(provider)} onClick={() => handleTestProvider(provider)}
disabled={!canTest || testing} disabled={!canTest || testing}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50" className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
title={!canTest ? 'Test route is not configured for this provider' : ''} title={!canTest ? t('memories.testRouteNotConfigured') : ''}
> >
{testing {testing
? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
: <Camera className="w-4 h-4" />} : <Camera className="w-4 h-4" />}
{t('memories.testConnection')} {t('memories.testConnection')}
</button> </button>
{connected && ( {connected ? (
<span className="text-xs font-medium text-green-600 flex items-center gap-1"> <span className="text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" /> <span className="w-2 h-2 bg-green-500 rounded-full" />
{t('memories.connected')} {t('memories.connected')}
</span> </span>
) : (
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')}
</span>
)} )}
</div> </div>
</div> </div>
+4 -4
View File
@@ -105,7 +105,7 @@ export default function TodoListPanel({ tripId, items }: { tripId: number; items
if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return } if (!name || categories.includes(name)) { setAddingCategory(false); setNewCategoryName(''); return }
addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any) addTodoItem(tripId, { name: t('todo.newItem'), category: name } as any)
.then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) }) .then(() => { setAddingCategory(false); setNewCategoryName(''); setFilter(name) })
.catch(err => toast.error(err instanceof Error ? err.message : 'Error')) .catch(err => toast.error(err instanceof Error ? err.message : t('common.error')))
} }
// Get category count (non-done items) // Get category count (non-done items)
@@ -479,7 +479,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
due_date: dueDate || null, category: category || null, due_date: dueDate || null, category: category || null,
assigned_user_id: assignedUserId, priority, assigned_user_id: assignedUserId, priority,
} as any) } as any)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
setSaving(false) setSaving(false)
} }
@@ -487,7 +487,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
try { try {
await deleteTodoItem(tripId, item.id) await deleteTodoItem(tripId, item.id)
onClose() onClose()
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
} }
const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' } const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', marginBottom: 4, display: 'block' }
@@ -663,7 +663,7 @@ function NewTaskPane({ tripId, categories, members, defaultCategory, onCreated,
assigned_user_id: assignedUserId, assigned_user_id: assignedUserId,
} as any) } as any)
if (item?.id) onCreated(item.id) if (item?.id) onCreated(item.id)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Error') } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
setSaving(false) setSaving(false)
} }
+51 -8
View File
@@ -46,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [uploadingCover, setUploadingCover] = useState(false) const [uploadingCover, setUploadingCover] = useState(false)
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]) const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
const [selectedMembers, setSelectedMembers] = useState<number[]>([]) const [selectedMembers, setSelectedMembers] = useState<number[]>([])
const [existingMembers, setExistingMembers] = useState<{ id: number; username: string }[]>([])
const [memberSelectValue, setMemberSelectValue] = useState('') const [memberSelectValue, setMemberSelectValue] = useState('')
useEffect(() => { useEffect(() => {
@@ -74,8 +75,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled) if (c?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(c.trip_reminders_enabled)
}).catch(() => {}) }).catch(() => {})
} }
if (!trip) { authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) if (trip) {
tripsApi.getMembers(trip.id).then(d => setExistingMembers(d.members || [])).catch(() => {})
} else {
setExistingMembers([])
} }
}, [trip, isOpen]) }, [trip, isOpen])
@@ -365,12 +369,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
</div> </div>
)} )}
{/* Members — only for new trips */} {/* Members */}
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && ( {allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5"> <label className="block text-sm font-medium text-slate-700 mb-1.5">
<UserPlus className="inline w-4 h-4 mr-1" />{t('dashboard.addMembers')} <UserPlus className="inline w-4 h-4 mr-1" />{isEditing ? t('dashboard.addMembers') : t('dashboard.addMembers')}
</label> </label>
{/* Existing members (editing mode) */}
{isEditing && existingMembers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{existingMembers.map(m => (
<span key={m.id}
onClick={async () => {
if (m.id === currentUser?.id) return
try {
await tripsApi.removeMember(trip!.id, m.id)
setExistingMembers(prev => prev.filter(x => x.id !== m.id))
toast.success(t('trips.memberRemoved', { username: m.username }))
} catch { toast.error(t('trips.memberRemoveError')) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)',
cursor: m.id === currentUser?.id ? 'default' : 'pointer',
border: '1px solid var(--border-primary)',
}}>
{m.username}
{m.id !== currentUser?.id && <X size={11} style={{ color: 'var(--text-faint)' }} />}
</span>
))}
</div>
)}
{/* Newly selected members (both modes) */}
{selectedMembers.length > 0 && ( {selectedMembers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{selectedMembers.map(uid => { {selectedMembers.map(uid => {
@@ -393,11 +423,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<CustomSelect <CustomSelect
value={memberSelectValue} value={memberSelectValue}
onChange={value => { onChange={async value => {
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') } if (!value) return
if (isEditing && trip?.id) {
const user = allUsers.find(u => u.id === Number(value))
if (user) {
try {
await tripsApi.addMember(trip.id, user.username)
setExistingMembers(prev => [...prev, { id: user.id, username: user.username }])
toast.success(t('trips.memberAdded', { username: user.username }))
} catch { toast.error(t('trips.memberAddError')) }
}
} else {
setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)])
}
setMemberSelectValue('')
}} }}
placeholder={t('dashboard.addMember')} placeholder={t('dashboard.addMember')}
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))} options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id) && !existingMembers.some(m => m.id === u.id)).map(u => ({ value: u.id, label: u.username }))}
searchable searchable
size="sm" size="sm"
/> />
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import { tripsApi, authApi, shareApi } from '../../api/client' import { tripsApi, authApi, shareApi } from '../../api/client'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
@@ -40,6 +40,11 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false }) const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
const toast = useToast() const toast = useToast()
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
return () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }
}, [])
useEffect(() => { useEffect(() => {
shareApi.getLink(tripId).then(d => { shareApi.getLink(tripId).then(d => {
@@ -77,7 +82,8 @@ function ShareLinkSection({ tripId, t }: { tripId: number; t: (key: string, para
if (shareUrl) { if (shareUrl) {
navigator.clipboard.writeText(shareUrl) navigator.clipboard.writeText(shareUrl)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
} }
} }
+29 -2
View File
@@ -1,7 +1,8 @@
import { useMemo, useState, useCallback } from 'react' import { useMemo, useState, useCallback, useEffect } from 'react'
import { useVacayStore } from '../../store/vacayStore' import { useVacayStore } from '../../store/vacayStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { isWeekend } from './holidays' import { isWeekend } from './holidays'
import { tripsApi } from '../../api/client'
import VacayMonthCard from './VacayMonthCard' import VacayMonthCard from './VacayMonthCard'
import { Building2, MousePointer2 } from 'lucide-react' import { Building2, MousePointer2 } from 'lucide-react'
@@ -9,6 +10,30 @@ export default function VacayCalendar() {
const { t } = useTranslation() const { t } = useTranslation()
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore() const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
const [companyMode, setCompanyMode] = useState(false) const [companyMode, setCompanyMode] = useState(false)
const [tripDates, setTripDates] = useState<Set<string>>(new Set())
useEffect(() => {
let cancelled = false
;(async () => {
try {
const data = await tripsApi.list()
const dates = new Set<string>()
for (const trip of data.trips || []) {
if (!trip.start_date || !trip.end_date) continue
const start = new Date(trip.start_date + 'T00:00:00')
const end = new Date(trip.end_date + 'T00:00:00')
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const y = d.getFullYear()
if (y === selectedYear) {
dates.add(`${y}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
}
}
}
if (!cancelled) setTripDates(dates)
} catch { /* ignore */ }
})()
return () => { cancelled = true }
}, [selectedYear])
const companyHolidaySet = useMemo(() => { const companyHolidaySet = useMemo(() => {
const s = new Set() const s = new Set()
@@ -45,7 +70,7 @@ export default function VacayCalendar() {
return ( return (
<div> <div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
{Array.from({ length: 12 }, (_, i) => ( {Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard <VacayMonthCard
key={i} key={i}
@@ -59,6 +84,8 @@ export default function VacayCalendar() {
companyMode={companyMode} companyMode={companyMode}
blockWeekends={blockWeekends} blockWeekends={blockWeekends}
weekendDays={weekendDays} weekendDays={weekendDays}
tripDates={tripDates}
weekStart={plan?.week_start ?? 1}
/> />
))} ))}
</div> </div>
+23 -10
View File
@@ -23,22 +23,26 @@ interface VacayMonthCardProps {
companyMode: boolean companyMode: boolean
blockWeekends: boolean blockWeekends: boolean
weekendDays?: number[] weekendDays?: number[]
tripDates?: Set<string>
weekStart?: number
} }
export default function VacayMonthCard({ export default function VacayMonthCard({
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap, year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6] onCellClick, companyMode, blockWeekends, weekendDays = [0, 6], tripDates, weekStart = 1
}: VacayMonthCardProps) { }: VacayMonthCardProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const weekdays = WEEKDAY_KEYS.map(k => t(k)) const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const
const orderedKeys = weekStart === 0 ? WEEKDAY_KEYS_SUNDAY : WEEKDAY_KEYS
const weekdays = orderedKeys.map(k => t(k))
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month]) const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
const weeks = useMemo(() => { const weeks = useMemo(() => {
const firstDay = new Date(year, month, 1) const firstDay = new Date(year, month, 1)
const daysInMonth = new Date(year, month + 1, 0).getDate() const daysInMonth = new Date(year, month + 1, 0).getDate()
let startDow = firstDay.getDay() - 1 let startDow = firstDay.getDay() - weekStart
if (startDow < 0) startDow = 6 if (startDow < 0) startDow += 7
const cells = [] const cells = []
for (let i = 0; i < startDow; i++) cells.push(null) for (let i = 0; i < startDow; i++) cells.push(null)
for (let d = 1; d <= daysInMonth; d++) cells.push(d) for (let d = 1; d <= daysInMonth; d++) cells.push(d)
@@ -57,11 +61,16 @@ export default function VacayMonthCard({
</div> </div>
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}> <div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
{weekdays.map((wd, i) => ( {weekdays.map((wd, i) => {
<div key={wd} className="text-center text-[10px] font-medium py-1" style={{ color: i >= 5 ? 'var(--text-faint)' : 'var(--text-muted)' }}> // Map column index back to JS day (0=Sun..6=Sat) to check if it's a weekend column
{wd} const jsDay = (i + weekStart) % 7
</div> const isWeekendCol = weekendDays.includes(jsDay)
))} return (
<div key={`${wd}-${i}`} className="text-center text-[10px] font-medium py-1" style={{ color: isWeekendCol ? 'var(--text-faint)' : 'var(--text-muted)' }}>
{wd}
</div>
)
})}
</div> </div>
<div> <div>
@@ -122,6 +131,10 @@ export default function VacayMonthCard({
</div> </div>
)} )}
{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] font-medium" style={{ <span className="relative z-[1] text-[11px] font-medium" style={{
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)', color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
fontWeight: dayEntries.length > 0 ? 700 : 500, fontWeight: dayEntries.length > 0 ? 700 : 500,
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react' import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { render } from '../../../tests/helpers/render' import { render } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -75,17 +75,7 @@ describe('VacaySettings', () => {
render(<VacaySettings onClose={vi.fn()} />) render(<VacaySettings onClose={vi.fn()} />)
// Day buttons should be visible (Mon, Tue, Wed, Thu, Fri, Sat, Sun) // Day buttons should be visible (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
// They have text from translation keys; in test env they fallback to keys or English const dayButtons = within(screen.getByTestId('weekend-days')).getAllByRole('button')
// Check that 7 day-selector buttons exist (they are inside the paddingLeft:36 div)
const allButtons = screen.getAllByRole('button')
// The day buttons are not toggle buttons (no inline-flex/rounded-full class)
const dayButtons = allButtons.filter(b =>
!b.className.includes('inline-flex') &&
!b.className.includes('rounded-full') &&
!b.className.includes('rounded-md') &&
!b.className.includes('rounded-xl') &&
!b.className.includes('rounded-lg')
)
// There should be 7 day buttons // There should be 7 day buttons
expect(dayButtons.length).toBe(7) expect(dayButtons.length).toBe(7)
}) })
@@ -98,14 +88,8 @@ describe('VacaySettings', () => {
}) })
render(<VacaySettings onClose={vi.fn()} />) render(<VacaySettings onClose={vi.fn()} />)
// When block_weekends is false, the day selector section is not rendered // When block_weekends is false, the weekend-days container is not rendered
// There should only be toggle buttons (4 toggles), no day buttons expect(screen.queryByTestId('weekend-days')).toBeNull()
const allButtons = screen.getAllByRole('button')
// None of the buttons should be day selectors (they have borderRadius:8 inline style)
const dayButtons = allButtons.filter(b =>
b.style.borderRadius === '8px' && b.style.padding === '4px 10px'
)
expect(dayButtons).toHaveLength(0)
}) })
it('FE-COMP-VACAYSETTINGS-005: clicking an active weekend day removes it', async () => { it('FE-COMP-VACAYSETTINGS-005: clicking an active weekend day removes it', async () => {
+33 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react' import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays } from 'lucide-react'
import { useVacayStore } from '../../store/vacayStore' import { useVacayStore } from '../../store/vacayStore'
import { getIntlLanguage, useTranslation } from '../../i18n' import { getIntlLanguage, useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
@@ -51,7 +51,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
{/* Weekend days selector */} {/* Weekend days selector */}
{plan.block_weekends !== false && ( {plan.block_weekends !== false && (
<div style={{ paddingLeft: 36 }}> <div data-testid="weekend-days" style={{ paddingLeft: 36 }}>
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p> <p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{[ {[
@@ -85,6 +85,37 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) {
</div> </div>
)} )}
{/* Week start */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<CalendarDays size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<div style={{ flex: 1 }}>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{t('vacay.weekStart')}</span>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('vacay.weekStartHint')}</p>
</div>
</div>
<div style={{ paddingLeft: 36, marginTop: 8 }} className="flex gap-1.5">
{[
{ value: 1, label: t('vacay.mon') },
{ value: 0, label: t('vacay.sun') },
].map(({ value, label }) => {
const active = (plan.week_start ?? 1) === value
return (
<button key={value} onClick={() => updatePlan({ week_start: value })}
style={{
padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
background: active ? 'var(--text-primary)' : 'var(--bg-card)',
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
}}>
{label}
</button>
)
})}
</div>
</div>
{/* Carry-over */} {/* Carry-over */}
<SettingToggle <SettingToggle
icon={ArrowRightLeft} icon={ArrowRightLeft}
+1 -1
View File
@@ -61,7 +61,7 @@ export default function Modal({
<div <div
className={` className={`
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md} rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[calc(100vh-90px)] flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
animate-in fade-in zoom-in-95 duration-200 animate-in fade-in zoom-in-95 duration-200
`} `}
style={{ style={{
+14 -4
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react' import React, { useState, useCallback, useEffect, useRef } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react' import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
type ToastType = 'success' | 'error' | 'warning' | 'info' type ToastType = 'success' | 'error' | 'warning' | 'info'
@@ -28,18 +28,27 @@ const ICON_COLORS: Record<ToastType, string> = {
export function ToastContainer() { export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]) const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => {
timersRef.current.forEach(clearTimeout)
}
}, [])
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => { const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
const id = ++toastIdCounter const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }]) setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) { if (duration > 0) {
setTimeout(() => { const t1 = setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => { const t2 = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts(prev => prev.filter(t => t.id !== id))
}, 400) }, 400)
timersRef.current.push(t2)
}, duration) }, duration)
timersRef.current.push(t1)
} }
return id return id
@@ -47,9 +56,10 @@ export function ToastContainer() {
const removeToast = useCallback((id: number) => { const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t)) setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => { const t = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts(prev => prev.filter(t => t.id !== id))
}, 400) }, 400)
timersRef.current.push(t)
}, []) }, [])
useEffect(() => { useEffect(() => {
+5 -3
View File
@@ -1,6 +1,7 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { useTripStore } from '../store/tripStore' import { useTripStore } from '../store/tripStore'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import { useTranslation } from '../i18n'
import type { MergedItem, DayNotesMap, DayNote } from '../types' import type { MergedItem, DayNotesMap, DayNote } from '../types'
interface NoteUiState { interface NoteUiState {
@@ -21,6 +22,7 @@ export function useDayNotes(tripId: number | string) {
const noteInputRef = useRef<HTMLInputElement | null>(null) const noteInputRef = useRef<HTMLInputElement | null>(null)
const tripStore = useTripStore() const tripStore = useTripStore()
const toast = useToast() const toast = useToast()
const { t } = useTranslation()
const dayNotes: DayNotesMap = tripStore.dayNotes || {} const dayNotes: DayNotesMap = tripStore.dayNotes || {}
const openAddNote = (dayId: number, getMergedItems: (dayId: number) => MergedItem[], expandDay?: (dayId: number) => void) => { const openAddNote = (dayId: number, getMergedItems: (dayId: number) => MergedItem[], expandDay?: (dayId: number) => void) => {
@@ -50,12 +52,12 @@ export function useDayNotes(tripId: number | string) {
await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' }) await tripStore.updateDayNote(tripId, dayId, ui.noteId!, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
} }
cancelNote(dayId) cancelNote(dayId)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
} }
const deleteNote = async (dayId: number, noteId: number) => { const deleteNote = async (dayId: number, noteId: number) => {
try { await tripStore.deleteDayNote(tripId, dayId, noteId) } try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
} }
const moveNote = async (dayId: number, noteId: number, direction: 'up' | 'down', getMergedItems: (dayId: number) => MergedItem[]) => { const moveNote = async (dayId: number, noteId: number, direction: 'up' | 'down', getMergedItems: (dayId: number) => MergedItem[]) => {
@@ -71,7 +73,7 @@ export function useDayNotes(tripId: number | string) {
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1 newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
} }
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) } try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
} }
return { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, moveNote } return { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, moveNote }
+192 -10
View File
@@ -12,6 +12,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.loading': 'جارٍ التحميل...', 'common.loading': 'جارٍ التحميل...',
'common.import': 'استيراد', 'common.import': 'استيراد',
'common.error': 'خطأ', 'common.error': 'خطأ',
'common.unknownError': 'خطأ غير معروف',
'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
'common.back': 'رجوع', 'common.back': 'رجوع',
'common.all': 'الكل', 'common.all': 'الكل',
'common.close': 'إغلاق', 'common.close': 'إغلاق',
@@ -31,6 +33,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'كلمة المرور', 'common.password': 'كلمة المرور',
'common.saving': 'جارٍ الحفظ...', 'common.saving': 'جارٍ الحفظ...',
'common.saved': 'تم الحفظ', 'common.saved': 'تم الحفظ',
'common.expand': 'توسيع',
'common.collapse': 'طي',
'trips.memberRemoved': '{username} تمت إزالته',
'trips.memberRemoveError': 'فشل في الإزالة',
'trips.memberAdded': '{username} تمت إضافته',
'trips.memberAddError': 'فشل في الإضافة',
'trips.reminder': 'تذكير', 'trips.reminder': 'تذكير',
'trips.reminderNone': 'بدون', 'trips.reminderNone': 'بدون',
'trips.reminderDay': 'يوم', 'trips.reminderDay': 'يوم',
@@ -184,9 +192,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.none': 'معطّل', 'admin.notifications.none': 'معطّل',
'admin.notifications.email': 'البريد الإلكتروني (SMTP)', 'admin.notifications.email': 'البريد الإلكتروني (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'أحداث الإشعارات',
'admin.notifications.eventsHint': 'اختر الأحداث التي تُفعّل الإشعارات لجميع المستخدمين.',
'admin.notifications.configureFirst': 'قم بتكوين إعدادات SMTP أو Webhook أدناه أولاً، ثم قم بتفعيل الأحداث.',
'admin.notifications.save': 'حفظ إعدادات الإشعارات', 'admin.notifications.save': 'حفظ إعدادات الإشعارات',
'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات', 'admin.notifications.saved': 'تم حفظ إعدادات الإشعارات',
'admin.notifications.testWebhook': 'إرسال webhook تجريبي', 'admin.notifications.testWebhook': 'إرسال webhook تجريبي',
@@ -233,6 +238,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'نقطة نهاية MCP', 'settings.mcp.endpoint': 'نقطة نهاية MCP',
'settings.mcp.clientConfig': 'إعداد العميل', 'settings.mcp.clientConfig': 'إعداد العميل',
'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).', 'settings.mcp.clientConfigHint': 'استبدل <your_token> برمز API من القائمة أدناه. قد يحتاج مسار npx إلى ضبط وفق نظامك (مثلاً C:\\PROGRA~1\\nodejs\\npx.cmd على Windows).',
'settings.mcp.clientConfigHintOAuth': 'استبدل <your_client_id> و<your_client_secret> ببيانات الاعتماد المعروضة في عميل OAuth 2.1 الذي أنشأته أعلاه. سيفتح mcp-remote متصفحك لإتمام التفويض في أول اتصال. قد يحتاج مسار npx إلى تعديل حسب نظامك (مثال: C:\PROGRA~1\nodejs\npx.cmd على Windows).',
'settings.mcp.copy': 'نسخ', 'settings.mcp.copy': 'نسخ',
'settings.mcp.copied': 'تم النسخ!', 'settings.mcp.copied': 'تم النسخ!',
'settings.mcp.apiTokens': 'رموز API', 'settings.mcp.apiTokens': 'رموز API',
@@ -254,6 +260,48 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'فشل إنشاء الرمز', 'settings.mcp.toast.createError': 'فشل إنشاء الرمز',
'settings.mcp.toast.deleted': 'تم حذف الرمز', 'settings.mcp.toast.deleted': 'تم حذف الرمز',
'settings.mcp.toast.deleteError': 'فشل حذف الرمز', 'settings.mcp.toast.deleteError': 'فشل حذف الرمز',
'settings.mcp.apiTokensDeprecated': 'رموز API قديمة وستُزال في إصدار مستقبلي. يُرجى استخدام عملاء OAuth 2.1 بدلاً منها.',
'settings.oauth.clients': 'عملاء OAuth 2.1',
'settings.oauth.clientsHint': 'سجّل عملاء OAuth 2.1 للسماح لتطبيقات MCP الخارجية (Claude Web وCursor وغيرها) بالاتصال دون رموز ثابتة.',
'settings.oauth.createClient': 'عميل جديد',
'settings.oauth.noClients': 'لا يوجد عملاء OAuth مسجلون.',
'settings.oauth.clientId': 'معرّف العميل',
'settings.oauth.clientSecret': 'سر العميل',
'settings.oauth.deleteClient': 'حذف العميل',
'settings.oauth.deleteClientMessage': 'سيتم حذف هذا العميل وجميع الجلسات النشطة بشكل دائم. ستفقد أي تطبيق يستخدمه وصوله فوراً.',
'settings.oauth.rotateSecret': 'تجديد السر',
'settings.oauth.rotateSecretMessage': 'سيتم إنشاء سر عميل جديد وإبطال جميع الجلسات الحالية فوراً. حدّث تطبيقك قبل إغلاق هذا الحوار.',
'settings.oauth.rotateSecretConfirm': 'تجديد',
'settings.oauth.rotateSecretConfirming': 'جارٍ التجديد…',
'settings.oauth.rotateSecretDoneTitle': 'تم إنشاء سر جديد',
'settings.oauth.rotateSecretDoneWarning': 'يُعرض هذا السر مرة واحدة فقط. انسخه الآن وحدّث تطبيقك — تم إبطال جميع الجلسات السابقة.',
'settings.oauth.activeSessions': 'جلسات OAuth النشطة',
'settings.oauth.sessionScopes': 'النطاقات',
'settings.oauth.sessionExpires': 'تنتهي',
'settings.oauth.revoke': 'إلغاء',
'settings.oauth.revokeSession': 'إلغاء الجلسة',
'settings.oauth.revokeSessionMessage': 'سيؤدي هذا إلى إلغاء الوصول لهذه الجلسة OAuth فوراً.',
'settings.oauth.modal.createTitle': 'تسجيل عميل OAuth',
'settings.oauth.modal.presets': 'إعدادات سريعة',
'settings.oauth.modal.clientName': 'اسم التطبيق',
'settings.oauth.modal.clientNamePlaceholder': 'مثال: Claude Web، تطبيق MCP الخاص بي',
'settings.oauth.modal.redirectUris': 'عناوين URI لإعادة التوجيه',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'عنوان URI واحد لكل سطر. يُطلب HTTPS (localhost مستثنى). يُطبق تطابق دقيق.',
'settings.oauth.modal.scopes': 'النطاقات المسموح بها',
'settings.oauth.modal.scopesHint': 'list_trips وget_trip_summary متاحان دائماً — لا يُطلب نطاق. يساعدان الذكاء الاصطناعي في اكتشاف معرّفات الرحلات.',
'settings.oauth.modal.selectAll': 'تحديد الكل',
'settings.oauth.modal.deselectAll': 'إلغاء تحديد الكل',
'settings.oauth.modal.creating': 'جارٍ التسجيل…',
'settings.oauth.modal.create': 'تسجيل العميل',
'settings.oauth.modal.createdTitle': 'تم تسجيل العميل',
'settings.oauth.modal.createdWarning': 'يُعرض سر العميل مرة واحدة فقط. انسخه الآن — لا يمكن استرداده.',
'settings.oauth.toast.createError': 'فشل تسجيل عميل OAuth',
'settings.oauth.toast.deleted': 'تم حذف عميل OAuth',
'settings.oauth.toast.deleteError': 'فشل حذف عميل OAuth',
'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
'settings.account': 'الحساب', 'settings.account': 'الحساب',
'settings.about': 'حول', 'settings.about': 'حول',
'settings.about.reportBug': 'الإبلاغ عن خطأ', 'settings.about.reportBug': 'الإبلاغ عن خطأ',
@@ -376,6 +424,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.mfaHint': 'افتح Google Authenticator أو Authy أو أي تطبيق TOTP آخر.', 'login.mfaHint': 'افتح Google Authenticator أو Authy أو أي تطبيق TOTP آخر.',
'login.mfaBack': '← العودة لتسجيل الدخول', 'login.mfaBack': '← العودة لتسجيل الدخول',
'login.mfaVerify': 'تحقق', 'login.mfaVerify': 'تحقق',
'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية',
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
// Register // Register
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين', 'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
@@ -410,9 +462,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.config': 'التخصيص', 'admin.tabs.config': 'التخصيص',
'admin.tabs.templates': 'قوالب التعبئة', 'admin.tabs.templates': 'قوالب التعبئة',
'admin.tabs.addons': 'الإضافات', 'admin.tabs.addons': 'الإضافات',
'admin.tabs.mcpTokens': 'رموز MCP', 'admin.tabs.mcpTokens': 'وصول MCP',
'admin.mcpTokens.title': 'رموز MCP', 'admin.mcpTokens.title': 'وصول MCP',
'admin.mcpTokens.subtitle': 'إدارة رموز API لجميع المستخدمين', 'admin.mcpTokens.subtitle': 'إدارة جلسات OAuth ورموز API لجميع المستخدمين',
'admin.mcpTokens.sectionTitle': 'رموز API',
'admin.mcpTokens.owner': 'المالك', 'admin.mcpTokens.owner': 'المالك',
'admin.mcpTokens.tokenName': 'اسم الرمز', 'admin.mcpTokens.tokenName': 'اسم الرمز',
'admin.mcpTokens.created': 'تاريخ الإنشاء', 'admin.mcpTokens.created': 'تاريخ الإنشاء',
@@ -424,6 +477,17 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز', 'admin.mcpTokens.deleteSuccess': 'تم حذف الرمز',
'admin.mcpTokens.deleteError': 'فشل حذف الرمز', 'admin.mcpTokens.deleteError': 'فشل حذف الرمز',
'admin.mcpTokens.loadError': 'فشل تحميل الرموز', 'admin.mcpTokens.loadError': 'فشل تحميل الرموز',
'admin.oauthSessions.sectionTitle': 'جلسات OAuth',
'admin.oauthSessions.clientName': 'العميل',
'admin.oauthSessions.owner': 'المالك',
'admin.oauthSessions.scopes': 'الصلاحيات',
'admin.oauthSessions.created': 'تاريخ الإنشاء',
'admin.oauthSessions.empty': 'لا توجد جلسات OAuth نشطة',
'admin.oauthSessions.revokeTitle': 'إلغاء الجلسة',
'admin.oauthSessions.revokeMessage': 'سيتم إلغاء جلسة OAuth هذه فوراً. سيفقد العميل وصوله إلى MCP.',
'admin.oauthSessions.revokeSuccess': 'تم إلغاء الجلسة',
'admin.oauthSessions.revokeError': 'فشل إلغاء الجلسة',
'admin.oauthSessions.loadError': 'فشل تحميل جلسات OAuth',
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
'admin.stats.users': 'المستخدمون', 'admin.stats.users': 'المستخدمون',
'admin.stats.trips': 'الرحلات', 'admin.stats.trips': 'الرحلات',
@@ -473,6 +537,17 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.invite.deleteError': 'فشل حذف رابط الدعوة', 'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
'admin.allowRegistration': 'السماح بالتسجيل', 'admin.allowRegistration': 'السماح بالتسجيل',
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم', 'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)', 'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.', 'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
'admin.apiKeys': 'مفاتيح API', 'admin.apiKeys': 'مفاتيح API',
@@ -664,6 +739,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'vacay.companyHolidays': 'عطل الشركة', 'vacay.companyHolidays': 'عطل الشركة',
'vacay.companyHolidaysHint': 'السماح بوضع علامة على أيام عطلات الشركة', 'vacay.companyHolidaysHint': 'السماح بوضع علامة على أيام عطلات الشركة',
'vacay.companyHolidaysNoDeduct': 'لا تُخصم عطل الشركة من أيام الإجازة.', 'vacay.companyHolidaysNoDeduct': 'لا تُخصم عطل الشركة من أيام الإجازة.',
'vacay.weekStart': 'يبدأ الأسبوع في',
'vacay.weekStartHint': 'اختر ما إذا كان الأسبوع يبدأ يوم الاثنين أو الأحد',
'vacay.carryOver': 'الترحيل', 'vacay.carryOver': 'الترحيل',
'vacay.carryOverHint': 'ترحيل أيام الإجازة المتبقية تلقائيًا إلى السنة التالية', 'vacay.carryOverHint': 'ترحيل أيام الإجازة المتبقية تلقائيًا إلى السنة التالية',
'vacay.sharing': 'المشاركة', 'vacay.sharing': 'المشاركة',
@@ -874,6 +951,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'inspector.files': 'الملفات', 'inspector.files': 'الملفات',
'inspector.filesCount': '{count} ملفات', 'inspector.filesCount': '{count} ملفات',
'inspector.removeFromDay': 'إزالة من اليوم', 'inspector.removeFromDay': 'إزالة من اليوم',
'inspector.remove': 'إزالة',
'inspector.addToDay': 'إضافة إلى اليوم', 'inspector.addToDay': 'إضافة إلى اليوم',
'inspector.confirmedRes': 'حجز مؤكد', 'inspector.confirmedRes': 'حجز مؤكد',
'inspector.pendingRes': 'حجز قيد الانتظار', 'inspector.pendingRes': 'حجز قيد الانتظار',
@@ -1014,6 +1092,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.totalBudget': 'إجمالي الميزانية', 'budget.totalBudget': 'إجمالي الميزانية',
'budget.byCategory': 'حسب الفئة', 'budget.byCategory': 'حسب الفئة',
'budget.editTooltip': 'انقر للتعديل', 'budget.editTooltip': 'انقر للتعديل',
'budget.linkedToReservation': 'مرتبط بحجز — عدّل الاسم هناك',
'budget.confirm.deleteCategory': 'هل تريد حذف الفئة "{name}" مع {count} إدخالات؟', 'budget.confirm.deleteCategory': 'هل تريد حذف الفئة "{name}" مع {count} إدخالات؟',
'budget.deleteCategory': 'حذف الفئة', 'budget.deleteCategory': 'حذف الفئة',
'budget.perPerson': 'لكل شخص', 'budget.perPerson': 'لكل شخص',
@@ -1023,9 +1102,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'التسوية', 'budget.settlement': 'التسوية',
'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.', 'budget.settlementInfo': 'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
'budget.netBalances': 'الأرصدة الصافية', 'budget.netBalances': 'الأرصدة الصافية',
'budget.linkedToReservation': 'مرتبط بحجز — قم بتحرير الاسم هناك',
// Files // Files
'files.title': 'الملفات', 'files.title': 'الملفات',
'files.pageTitle': 'الملفات والمستندات',
'files.subtitle': '{count} ملف لـ {trip}',
'files.downloadPdf': 'تنزيل PDF',
'files.count': '{count} ملفات', 'files.count': '{count} ملفات',
'files.countSingular': 'ملف واحد', 'files.countSingular': 'ملف واحد',
'files.uploaded': 'تم رفع {count}', 'files.uploaded': 'تم رفع {count}',
@@ -1104,7 +1187,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'packing.menuCheckAll': 'تحديد الكل', 'packing.menuCheckAll': 'تحديد الكل',
'packing.menuUncheckAll': 'إلغاء تحديد الكل', 'packing.menuUncheckAll': 'إلغاء تحديد الكل',
'packing.menuDeleteCat': 'حذف الفئة', 'packing.menuDeleteCat': 'حذف الفئة',
'packing.assignUser': 'تعيين مستخدم',
'packing.noMembers': 'لا أعضاء', 'packing.noMembers': 'لا أعضاء',
'packing.addItem': 'إضافة عنصر', 'packing.addItem': 'إضافة عنصر',
'packing.addItemPlaceholder': 'اسم العنصر...', 'packing.addItemPlaceholder': 'اسم العنصر...',
@@ -1114,6 +1196,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'packing.template': 'قالب', 'packing.template': 'قالب',
'packing.templateApplied': 'تمت إضافة {count} عنصر من القالب', 'packing.templateApplied': 'تمت إضافة {count} عنصر من القالب',
'packing.templateError': 'فشل تطبيق القالب', 'packing.templateError': 'فشل تطبيق القالب',
'packing.saveAsTemplate': 'حفظ كقالب',
'packing.templateName': 'اسم القالب',
'packing.templateSaved': 'تم حفظ قائمة الحقائب كقالب',
'packing.bags': 'أمتعة', 'packing.bags': 'أمتعة',
'packing.noBag': 'غير معيّن', 'packing.noBag': 'غير معيّن',
'packing.totalWeight': 'الوزن الإجمالي', 'packing.totalWeight': 'الوزن الإجمالي',
@@ -1269,6 +1354,13 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'backup.keep.forever': 'الاحتفاظ للأبد', 'backup.keep.forever': 'الاحتفاظ للأبد',
// Photos // Photos
'photos.title': 'صور',
'photos.subtitle': '{count} صورة لـ {trip}',
'photos.dropHere': 'أسقط الصور هنا...',
'photos.dropHereActive': 'أسقط الصور هنا',
'photos.captionForAll': 'تعليق (للجميع)',
'photos.captionPlaceholder': 'تعليق اختياري...',
'photos.addCaption': 'إضافة تعليق...',
'photos.allDays': 'كل الأيام', 'photos.allDays': 'كل الأيام',
'photos.noPhotos': 'لا توجد صور بعد', 'photos.noPhotos': 'لا توجد صور بعد',
'photos.uploadHint': 'ارفع صور رحلتك', 'photos.uploadHint': 'ارفع صور رحلتك',
@@ -1276,6 +1368,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'photos.linkPlace': 'ربط بمكان', 'photos.linkPlace': 'ربط بمكان',
'photos.noPlace': 'بلا مكان', 'photos.noPlace': 'بلا مكان',
'photos.uploadN': 'رفع {n} صورة', 'photos.uploadN': 'رفع {n} صورة',
'photos.linkDay': 'ربط اليوم',
'photos.noDay': 'لا يوم',
'photos.dayLabel': 'اليوم {number}',
'photos.photoSelected': 'صورة محددة',
'photos.photosSelected': 'صور محددة',
'photos.fileTypeHint': 'JPG, PNG, WebP · الحد الأقصى 10 ميغابايت · حتى 30 صورة',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'استعادة النسخة الاحتياطية؟', 'backup.restoreConfirmTitle': 'استعادة النسخة الاحتياطية؟',
@@ -1302,6 +1400,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'planner.routeCalculated': 'تم حساب المسار', 'planner.routeCalculated': 'تم حساب المسار',
'planner.routeCalcFailed': 'تعذر حساب المسار', 'planner.routeCalcFailed': 'تعذر حساب المسار',
'planner.routeError': 'خطأ أثناء حساب المسار', 'planner.routeError': 'خطأ أثناء حساب المسار',
'planner.icsExportFailed': 'فشل تصدير ICS',
'planner.routeOptimized': 'تم تحسين المسار', 'planner.routeOptimized': 'تم تحسين المسار',
'planner.reservationUpdated': 'تم تحديث الحجز', 'planner.reservationUpdated': 'تم تحديث الحجز',
'planner.reservationAdded': 'تمت إضافة الحجز', 'planner.reservationAdded': 'تمت إضافة الحجز',
@@ -1387,6 +1486,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'صور', 'memories.title': 'صور',
'memories.notConnected': 'Immich غير متصل', 'memories.notConnected': 'Immich غير متصل',
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.', 'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
'memories.notConnectedMultipleHint': 'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.',
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.', 'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
'memories.noPhotos': 'لم يتم العثور على صور', 'memories.noPhotos': 'لم يتم العثور على صور',
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.', 'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
@@ -1397,26 +1497,38 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'مراجعة صورك', 'memories.reviewTitle': 'مراجعة صورك',
'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.', 'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.',
'memories.shareCount': 'مشاركة {count} صور', 'memories.shareCount': 'مشاركة {count} صور',
'memories.immichUrl': 'عنوان خادم Immich', 'memories.providerUrl': 'عنوان URL للخادم',
'memories.immichApiKey': 'مفتاح API', 'memories.providerApiKey': 'مفتاح API',
'memories.providerUsername': 'اسم المستخدم',
'memories.providerPassword': 'كلمة المرور',
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال', 'memories.testConnection': 'اختبار الاتصال',
'memories.testFirst': 'اختبر الاتصال أولاً', 'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل', 'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل', 'memories.disconnected': 'غير متصل',
'memories.connectionSuccess': 'تم الاتصال بـ Immich', 'memories.connectionSuccess': 'تم الاتصال بـ Immich',
'memories.connectionError': 'تعذر الاتصال بـ Immich', 'memories.connectionError': 'تعذر الاتصال بـ Immich',
'memories.saved': 'تم حفظ إعدادات Immich', 'memories.saved': 'تم حفظ إعدادات {provider_name}',
'memories.providerDisconnectedBanner': 'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}',
'memories.saveRouteNotConfigured': 'مسار الحفظ غير مهيأ لهذا المزود',
'memories.testRouteNotConfigured': 'مسار الاختبار غير مهيأ لهذا المزود',
'memories.fillRequiredFields': 'يرجى ملء جميع الحقول المطلوبة',
'memories.oldest': 'الأقدم أولاً', 'memories.oldest': 'الأقدم أولاً',
'memories.newest': 'الأحدث أولاً', 'memories.newest': 'الأحدث أولاً',
'memories.allLocations': 'جميع المواقع', 'memories.allLocations': 'جميع المواقع',
'memories.addPhotos': 'إضافة صور', 'memories.addPhotos': 'إضافة صور',
'memories.linkAlbum': 'ربط ألبوم', 'memories.linkAlbum': 'ربط ألبوم',
'memories.selectAlbum': 'اختيار ألبوم Immich', 'memories.selectAlbum': 'اختيار ألبوم Immich',
'memories.selectAlbumMultiple': 'اختيار ألبوم',
'memories.noAlbums': 'لم يتم العثور على ألبومات', 'memories.noAlbums': 'لم يتم العثور على ألبومات',
'memories.syncAlbum': 'مزامنة الألبوم', 'memories.syncAlbum': 'مزامنة الألبوم',
'memories.unlinkAlbum': 'إلغاء الربط', 'memories.unlinkAlbum': 'إلغاء الربط',
'memories.photos': 'صور', 'memories.photos': 'صور',
'memories.selectPhotos': 'اختيار صور من Immich', 'memories.selectPhotos': 'اختيار صور من Immich',
'memories.selectPhotosMultiple': 'اختيار الصور',
'memories.selectHint': 'انقر على الصور لتحديدها.', 'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد', 'memories.selected': 'محدد',
'memories.addSelected': 'إضافة {count} صور', 'memories.addSelected': 'إضافة {count} صور',
@@ -1428,6 +1540,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟', 'memories.confirmShareTitle': 'مشاركة مع أعضاء الرحلة؟',
'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.', 'memories.confirmShareHint': '{count} صور ستكون مرئية لجميع أعضاء هذه الرحلة. يمكنك جعل الصور الفردية خاصة لاحقًا.',
'memories.confirmShareButton': 'مشاركة الصور', 'memories.confirmShareButton': 'مشاركة الصور',
'journey.settings.failedToDelete': 'فشل في الحذف',
'journey.entries.deleteTitle': 'حذف الإدخال',
'journey.photosUploaded': 'تم رفع {count} صورة',
'journey.photosAdded': 'تمت إضافة {count} صورة',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'الدردشة', 'collab.tabs.chat': 'الدردشة',
@@ -1574,6 +1690,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'تحديد كغير مقروء', 'notifications.markUnread': 'تحديد كغير مقروء',
'notifications.delete': 'حذف', 'notifications.delete': 'حذف',
'notifications.system': 'النظام', 'notifications.system': 'النظام',
'notifications.synologySessionCleared.title': 'تم قطع اتصال Synology Photos',
'notifications.synologySessionCleared.text': 'تغير خادمك أو حسابك — انتقل إلى الإعدادات لاختبار اتصالك مرة أخرى.',
'memories.error.loadAlbums': 'فشل تحميل الألبومات', 'memories.error.loadAlbums': 'فشل تحميل الألبومات',
'memories.error.linkAlbum': 'فشل ربط الألبوم', 'memories.error.linkAlbum': 'فشل ربط الألبوم',
'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم', 'memories.error.unlinkAlbum': 'فشل إلغاء ربط الألبوم',
@@ -1696,6 +1814,70 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'لديك إشعار جديد', 'notif.generic.text': 'لديك إشعار جديد',
'notif.dev.unknown_event.title': '[DEV] حدث غير معروف', 'notif.dev.unknown_event.title': '[DEV] حدث غير معروف',
'notif.dev.unknown_event.text': 'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'نوع الحدث "{event}" غير مسجل في EVENT_NOTIFICATION_CONFIG',
// OAuth scope groups
'oauth.scope.group.trips': 'الرحلات',
'oauth.scope.group.places': 'الأماكن',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'الأمتعة',
'oauth.scope.group.todos': 'المهام',
'oauth.scope.group.budget': 'الميزانية',
'oauth.scope.group.reservations': 'الحجوزات',
'oauth.scope.group.collab': 'التعاون',
'oauth.scope.group.notifications': 'الإشعارات',
'oauth.scope.group.vacay': 'الإجازة',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'الطقس',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر',
'oauth.scope.trips:read.description': 'قراءة الرحلات والأيام والملاحظات والأعضاء',
'oauth.scope.trips:write.label': 'تحرير الرحلات وخطط السفر',
'oauth.scope.trips:write.description': 'إنشاء وتحديث الرحلات والأيام والملاحظات وإدارة الأعضاء',
'oauth.scope.trips:delete.label': 'حذف الرحلات',
'oauth.scope.trips:delete.description': 'حذف الرحلات بأكملها نهائياً — هذا الإجراء لا يمكن التراجع عنه',
'oauth.scope.trips:share.label': 'إدارة روابط المشاركة',
'oauth.scope.trips:share.description': 'إنشاء روابط مشاركة عامة وتحديثها وإلغاؤها',
'oauth.scope.places:read.label': 'عرض الأماكن وبيانات الخريطة',
'oauth.scope.places:read.description': 'قراءة الأماكن وتعيينات الأيام والعلامات والفئات',
'oauth.scope.places:write.label': 'إدارة الأماكن',
'oauth.scope.places:write.description': 'إنشاء وتحديث وحذف الأماكن والتعيينات والعلامات',
'oauth.scope.atlas:read.label': 'عرض Atlas',
'oauth.scope.atlas:read.description': 'قراءة الدول والمناطق المزارة وقائمة الأمنيات',
'oauth.scope.atlas:write.label': 'إدارة Atlas',
'oauth.scope.atlas:write.description': 'تعليم الدول والمناطق كمزارة، وإدارة قائمة الأمنيات',
'oauth.scope.packing:read.label': 'عرض قوائم الأمتعة',
'oauth.scope.packing:read.description': 'قراءة عناصر الأمتعة والحقائب ومُسنَدي الفئات',
'oauth.scope.packing:write.label': 'إدارة قوائم الأمتعة',
'oauth.scope.packing:write.description': 'إضافة وتحديث وحذف وتبديل وإعادة ترتيب عناصر الأمتعة والحقائب',
'oauth.scope.todos:read.label': 'عرض قوائم المهام',
'oauth.scope.todos:read.description': 'قراءة مهام الرحلة ومُسنَدي الفئات',
'oauth.scope.todos:write.label': 'إدارة قوائم المهام',
'oauth.scope.todos:write.description': 'إنشاء وتحديث وتبديل وحذف وإعادة ترتيب المهام',
'oauth.scope.budget:read.label': 'عرض الميزانية',
'oauth.scope.budget:read.description': 'قراءة بنود الميزانية وتفاصيل النفقات',
'oauth.scope.budget:write.label': 'إدارة الميزانية',
'oauth.scope.budget:write.description': 'إنشاء وتحديث وحذف بنود الميزانية',
'oauth.scope.reservations:read.label': 'عرض الحجوزات',
'oauth.scope.reservations:read.description': 'قراءة الحجوزات وتفاصيل الإقامة',
'oauth.scope.reservations:write.label': 'إدارة الحجوزات',
'oauth.scope.reservations:write.description': 'إنشاء وتحديث وحذف وإعادة ترتيب الحجوزات',
'oauth.scope.collab:read.label': 'عرض التعاون',
'oauth.scope.collab:read.description': 'قراءة ملاحظات التعاون والاستطلاعات والرسائل',
'oauth.scope.collab:write.label': 'إدارة التعاون',
'oauth.scope.collab:write.description': 'إنشاء وتحديث وحذف الملاحظات والاستطلاعات والرسائل التعاونية',
'oauth.scope.notifications:read.label': 'عرض الإشعارات',
'oauth.scope.notifications:read.description': 'قراءة إشعارات التطبيق وأعداد غير المقروءة',
'oauth.scope.notifications:write.label': 'إدارة الإشعارات',
'oauth.scope.notifications:write.description': 'تعليم الإشعارات كمقروءة والرد عليها',
'oauth.scope.vacay:read.label': 'عرض خطط الإجازة',
'oauth.scope.vacay:read.description': 'قراءة بيانات تخطيط الإجازة والإدخالات والإحصاءات',
'oauth.scope.vacay:write.label': 'إدارة خطط الإجازة',
'oauth.scope.vacay:write.description': 'إنشاء وإدارة إدخالات الإجازة والعطلات وخطط الفريق',
'oauth.scope.geo:read.label': 'الخرائط والترميز الجغرافي',
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
'oauth.scope.weather:read.label': 'توقعات الطقس',
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
} }
export default ar export default ar
+431 -13
View File
@@ -8,6 +8,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.loading': 'Carregando...', 'common.loading': 'Carregando...',
'common.import': 'Importar', 'common.import': 'Importar',
'common.error': 'Erro', 'common.error': 'Erro',
'common.unknownError': 'Erro desconhecido',
'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
'common.back': 'Voltar', 'common.back': 'Voltar',
'common.all': 'Todos', 'common.all': 'Todos',
'common.close': 'Fechar', 'common.close': 'Fechar',
@@ -27,11 +29,17 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'Senha', 'common.password': 'Senha',
'common.saving': 'Salvando...', 'common.saving': 'Salvando...',
'common.saved': 'Salvo', 'common.saved': 'Salvo',
'common.expand': 'Expandir',
'common.collapse': 'Recolher',
'trips.reminder': 'Lembrete', 'trips.reminder': 'Lembrete',
'trips.reminderNone': 'Nenhum', 'trips.reminderNone': 'Nenhum',
'trips.reminderDay': 'dia', 'trips.reminderDay': 'dia',
'trips.reminderDays': 'dias', 'trips.reminderDays': 'dias',
'trips.reminderCustom': 'Personalizado', 'trips.reminderCustom': 'Personalizado',
'trips.memberRemoved': '{username} removido',
'trips.memberRemoveError': 'Falha ao remover',
'trips.memberAdded': '{username} adicionado',
'trips.memberAddError': 'Falha ao adicionar',
'trips.reminderDaysBefore': 'dias antes da partida', 'trips.reminderDaysBefore': 'dias antes da partida',
'trips.reminderDisabledHint': 'Os lembretes de viagem estão desativados. Ative-os em Admin > Configurações > Notificações.', 'trips.reminderDisabledHint': 'Os lembretes de viagem estão desativados. Ative-os em Admin > Configurações > Notificações.',
'common.update': 'Atualizar', 'common.update': 'Atualizar',
@@ -179,9 +187,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.none': 'Desativado', 'admin.notifications.none': 'Desativado',
'admin.notifications.email': 'E-mail (SMTP)', 'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificação',
'admin.notifications.eventsHint': 'Escolha quais eventos acionam notificações para todos os usuários.',
'admin.notifications.configureFirst': 'Configure primeiro as configurações SMTP ou webhook abaixo, depois ative os eventos.',
'admin.notifications.save': 'Salvar configurações de notificação', 'admin.notifications.save': 'Salvar configurações de notificação',
'admin.notifications.saved': 'Configurações de notificação salvas', 'admin.notifications.saved': 'Configurações de notificação salvas',
'admin.notifications.testWebhook': 'Enviar webhook de teste', 'admin.notifications.testWebhook': 'Enviar webhook de teste',
@@ -295,6 +300,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configuração do cliente', 'settings.mcp.clientConfig': 'Configuração do cliente',
'settings.mcp.clientConfigHint': 'Substitua <your_token> por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).', 'settings.mcp.clientConfigHint': 'Substitua <your_token> por um token de API da lista abaixo. O caminho para o npx pode precisar ser ajustado para o seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
'settings.mcp.clientConfigHintOAuth': 'Substitua <your_client_id> e <your_client_secret> pelas credenciais exibidas no cliente OAuth 2.1 criado acima. O mcp-remote abrirá seu navegador para concluir a autorização na primeira conexão. O caminho para o npx pode precisar ser ajustado para seu sistema (ex.: C:\\PROGRA~1\\nodejs\\npx.cmd no Windows).',
'settings.mcp.copy': 'Copiar', 'settings.mcp.copy': 'Copiar',
'settings.mcp.copied': 'Copiado!', 'settings.mcp.copied': 'Copiado!',
'settings.mcp.apiTokens': 'Tokens de API', 'settings.mcp.apiTokens': 'Tokens de API',
@@ -316,6 +322,48 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'Falha ao criar token', 'settings.mcp.toast.createError': 'Falha ao criar token',
'settings.mcp.toast.deleted': 'Token excluído', 'settings.mcp.toast.deleted': 'Token excluído',
'settings.mcp.toast.deleteError': 'Falha ao excluir token', 'settings.mcp.toast.deleteError': 'Falha ao excluir token',
'settings.mcp.apiTokensDeprecated': 'Os tokens de API estão obsoletos e serão removidos em uma versão futura. Por favor, use Clientes OAuth 2.1.',
'settings.oauth.clients': 'Clientes OAuth 2.1',
'settings.oauth.clientsHint': 'Registre clientes OAuth 2.1 para permitir que aplicações MCP de terceiros (Claude Web, Cursor, etc.) se conectem sem tokens estáticos.',
'settings.oauth.createClient': 'Novo cliente',
'settings.oauth.noClients': 'Nenhum cliente OAuth registrado.',
'settings.oauth.clientId': 'ID do cliente',
'settings.oauth.clientSecret': 'Segredo do cliente',
'settings.oauth.deleteClient': 'Excluir cliente',
'settings.oauth.deleteClientMessage': 'Este cliente e todas as sessões ativas serão removidos permanentemente. Qualquer aplicação que o utilize perderá o acesso imediatamente.',
'settings.oauth.rotateSecret': 'Renovar segredo',
'settings.oauth.rotateSecretMessage': 'Um novo segredo de cliente será gerado e todas as sessões existentes serão invalidadas imediatamente. Atualize sua aplicação antes de fechar esta janela.',
'settings.oauth.rotateSecretConfirm': 'Renovar',
'settings.oauth.rotateSecretConfirming': 'Renovando…',
'settings.oauth.rotateSecretDoneTitle': 'Novo segredo gerado',
'settings.oauth.rotateSecretDoneWarning': 'Este segredo é exibido apenas uma vez. Copie-o agora e atualize sua aplicação — todas as sessões anteriores foram invalidadas.',
'settings.oauth.activeSessions': 'Sessões OAuth ativas',
'settings.oauth.sessionScopes': 'Escopos',
'settings.oauth.sessionExpires': 'Expira',
'settings.oauth.revoke': 'Revogar',
'settings.oauth.revokeSession': 'Revogar sessão',
'settings.oauth.revokeSessionMessage': 'Isso revogará imediatamente o acesso desta sessão OAuth.',
'settings.oauth.modal.createTitle': 'Registrar cliente OAuth',
'settings.oauth.modal.presets': 'Configurações rápidas',
'settings.oauth.modal.clientName': 'Nome da aplicação',
'settings.oauth.modal.clientNamePlaceholder': 'ex.: Claude Web, Meu app MCP',
'settings.oauth.modal.redirectUris': 'URIs de redirecionamento',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Uma URI por linha. HTTPS obrigatório (localhost isento). Correspondência exata.',
'settings.oauth.modal.scopes': 'Escopos permitidos',
'settings.oauth.modal.scopesHint': 'list_trips e get_trip_summary estão sempre disponíveis — sem escopo necessário. Permitem à IA descobrir IDs de viagem.',
'settings.oauth.modal.selectAll': 'Selecionar tudo',
'settings.oauth.modal.deselectAll': 'Desmarcar tudo',
'settings.oauth.modal.creating': 'Registrando…',
'settings.oauth.modal.create': 'Registrar cliente',
'settings.oauth.modal.createdTitle': 'Cliente registrado',
'settings.oauth.modal.createdWarning': 'O segredo do cliente é exibido apenas uma vez. Copie-o agora — não pode ser recuperado.',
'settings.oauth.toast.createError': 'Falha ao registrar cliente OAuth',
'settings.oauth.toast.deleted': 'Cliente OAuth excluído',
'settings.oauth.toast.deleteError': 'Falha ao excluir cliente OAuth',
'settings.oauth.toast.revoked': 'Sessão revogada',
'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.', 'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
// Login // Login
@@ -371,6 +419,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.mfaHint': 'Abra o Google Authenticator, Authy ou outro app TOTP.', 'login.mfaHint': 'Abra o Google Authenticator, Authy ou outro app TOTP.',
'login.mfaBack': '← Voltar ao login', 'login.mfaBack': '← Voltar ao login',
'login.mfaVerify': 'Verificar', 'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Link de convite inválido ou expirado',
'login.oidcFailed': 'Falha no login OIDC',
'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
// Register // Register
'register.passwordMismatch': 'As senhas não coincidem', 'register.passwordMismatch': 'As senhas não coincidem',
@@ -449,6 +501,17 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Configurações', 'admin.tabs.settings': 'Configurações',
'admin.allowRegistration': 'Permitir cadastro', 'admin.allowRegistration': 'Permitir cadastro',
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos', 'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)', 'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.', 'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
'admin.apiKeys': 'Chaves de API', 'admin.apiKeys': 'Chaves de API',
@@ -463,7 +526,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.keyValid': 'Conectado', 'admin.keyValid': 'Conectado',
'admin.keyInvalid': 'Inválida', 'admin.keyInvalid': 'Inválida',
'admin.keySaved': 'Chaves de API salvas', 'admin.keySaved': 'Chaves de API salvas',
'admin.oidcTitle': 'Single Sign-On (OIDC)', 'admin.oidcTitle': 'Login Único (OIDC)',
'admin.oidcSubtitle': 'Permitir login via provedores externos como Google, Apple, Authentik ou Keycloak.', 'admin.oidcSubtitle': 'Permitir login via provedores externos como Google, Apple, Authentik ou Keycloak.',
'admin.oidcDisplayName': 'Nome exibido', 'admin.oidcDisplayName': 'Nome exibido',
'admin.oidcIssuer': 'URL do emissor', 'admin.oidcIssuer': 'URL do emissor',
@@ -513,7 +576,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem', 'admin.addons.catalog.budget.description': 'Acompanhe despesas e planeje o orçamento da viagem',
'admin.addons.catalog.documents.name': 'Documentos', 'admin.addons.catalog.documents.name': 'Documentos',
'admin.addons.catalog.documents.description': 'Armazene e gerencie documentos de viagem', 'admin.addons.catalog.documents.description': 'Armazene e gerencie documentos de viagem',
'admin.addons.catalog.vacay.name': 'Vacay', 'admin.addons.catalog.vacay.name': 'Férias',
'admin.addons.catalog.vacay.description': 'Planejador de férias pessoal com visão em calendário', 'admin.addons.catalog.vacay.description': 'Planejador de férias pessoal com visão em calendário',
'admin.addons.catalog.atlas.name': 'Atlas', 'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas', 'admin.addons.catalog.atlas.description': 'Mapa mundial com países visitados e estatísticas',
@@ -546,7 +609,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.requestsDesc': 'Grátis, sem chave de API', 'admin.weather.requestsDesc': 'Grátis, sem chave de API',
'admin.weather.locationHint': 'O clima usa o primeiro lugar com coordenadas de cada dia. Se nenhum lugar estiver atribuído ao dia, qualquer lugar da lista serve como referência.', 'admin.weather.locationHint': 'O clima usa o primeiro lugar com coordenadas de cada dia. Se nenhum lugar estiver atribuído ao dia, qualquer lugar da lista serve como referência.',
'admin.tabs.audit': 'Audit', 'admin.tabs.audit': 'Auditoria',
'admin.audit.subtitle': 'Eventos sensíveis de segurança e administração (backups, usuários, 2FA, configurações).', 'admin.audit.subtitle': 'Eventos sensíveis de segurança e administração (backups, usuários, 2FA, configurações).',
'admin.audit.empty': 'Nenhum registro de auditoria.', 'admin.audit.empty': 'Nenhum registro de auditoria.',
@@ -645,6 +708,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'vacay.companyHolidays': 'Feriados da empresa', 'vacay.companyHolidays': 'Feriados da empresa',
'vacay.companyHolidaysHint': 'Permitir marcar dias de feriado em toda a empresa', 'vacay.companyHolidaysHint': 'Permitir marcar dias de feriado em toda a empresa',
'vacay.companyHolidaysNoDeduct': 'Feriados da empresa não contam como dias de férias.', 'vacay.companyHolidaysNoDeduct': 'Feriados da empresa não contam como dias de férias.',
'vacay.weekStart': 'Semana começa em',
'vacay.weekStartHint': 'Escolha se a semana começa na segunda-feira ou no domingo',
'vacay.carryOver': 'Acúmulo', 'vacay.carryOver': 'Acúmulo',
'vacay.carryOverHint': 'Levar automaticamente os dias de férias restantes para o ano seguinte', 'vacay.carryOverHint': 'Levar automaticamente os dias de férias restantes para o ano seguinte',
'vacay.sharing': 'Compartilhamento', 'vacay.sharing': 'Compartilhamento',
@@ -855,6 +920,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'inspector.files': 'Arquivos', 'inspector.files': 'Arquivos',
'inspector.filesCount': '{count} arquivos', 'inspector.filesCount': '{count} arquivos',
'inspector.removeFromDay': 'Remover do dia', 'inspector.removeFromDay': 'Remover do dia',
'inspector.remove': 'Remover',
'inspector.addToDay': 'Adicionar ao dia', 'inspector.addToDay': 'Adicionar ao dia',
'inspector.confirmedRes': 'Reserva confirmada', 'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendente', 'inspector.pendingRes': 'Reserva pendente',
@@ -995,6 +1061,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'budget.totalBudget': 'Orçamento total', 'budget.totalBudget': 'Orçamento total',
'budget.byCategory': 'Por categoria', 'budget.byCategory': 'Por categoria',
'budget.editTooltip': 'Clique para editar', 'budget.editTooltip': 'Clique para editar',
'budget.linkedToReservation': 'Vinculado a uma reserva — edite o nome por lá',
'budget.confirm.deleteCategory': 'Excluir a categoria "{name}" com {count} lançamento(s)?', 'budget.confirm.deleteCategory': 'Excluir a categoria "{name}" com {count} lançamento(s)?',
'budget.deleteCategory': 'Excluir categoria', 'budget.deleteCategory': 'Excluir categoria',
'budget.perPerson': 'Por pessoa', 'budget.perPerson': 'Por pessoa',
@@ -1004,9 +1071,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Acerto', 'budget.settlement': 'Acerto',
'budget.settlementInfo': 'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.', 'budget.settlementInfo': 'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.',
'budget.netBalances': 'Saldos líquidos', 'budget.netBalances': 'Saldos líquidos',
'budget.linkedToReservation': 'Vinculado a uma reserva — edite o nome lá',
// Files // Files
'files.title': 'Arquivos', 'files.title': 'Arquivos',
'files.pageTitle': 'Arquivos e documentos',
'files.subtitle': '{count} arquivos para {trip}',
'files.downloadPdf': 'Baixar PDF',
'files.count': '{count} arquivos', 'files.count': '{count} arquivos',
'files.countSingular': '1 arquivo', 'files.countSingular': '1 arquivo',
'files.uploaded': '{count} enviado(s)', 'files.uploaded': '{count} enviado(s)',
@@ -1075,6 +1146,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'packing.allPacked': 'Tudo na mala!', 'packing.allPacked': 'Tudo na mala!',
'packing.addPlaceholder': 'Adicionar item...', 'packing.addPlaceholder': 'Adicionar item...',
'packing.categoryPlaceholder': 'Categoria...', 'packing.categoryPlaceholder': 'Categoria...',
'packing.saveAsTemplate': 'Salvar como modelo',
'packing.templateName': 'Nome do modelo',
'packing.templateSaved': 'Lista de bagagem salva como modelo',
'packing.filterAll': 'Todos', 'packing.filterAll': 'Todos',
'packing.filterOpen': 'Abertos', 'packing.filterOpen': 'Abertos',
'packing.filterDone': 'Prontos', 'packing.filterDone': 'Prontos',
@@ -1085,7 +1159,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'packing.menuCheckAll': 'Marcar todos', 'packing.menuCheckAll': 'Marcar todos',
'packing.menuUncheckAll': 'Desmarcar todos', 'packing.menuUncheckAll': 'Desmarcar todos',
'packing.menuDeleteCat': 'Excluir categoria', 'packing.menuDeleteCat': 'Excluir categoria',
'packing.assignUser': 'Atribuir usuário',
'packing.noMembers': 'Nenhum membro na viagem', 'packing.noMembers': 'Nenhum membro na viagem',
'packing.addItem': 'Adicionar item', 'packing.addItem': 'Adicionar item',
'packing.addItemPlaceholder': 'Nome do item...', 'packing.addItemPlaceholder': 'Nome do item...',
@@ -1095,6 +1168,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'packing.template': 'Modelo', 'packing.template': 'Modelo',
'packing.templateApplied': '{count} itens adicionados do modelo', 'packing.templateApplied': '{count} itens adicionados do modelo',
'packing.templateError': 'Falha ao aplicar modelo', 'packing.templateError': 'Falha ao aplicar modelo',
'packing.saveAsTemplate': 'Salvar como modelo',
'packing.templateName': 'Nome do modelo',
'packing.templateSaved': 'Lista de bagagem salva como modelo',
'packing.bags': 'Malas', 'packing.bags': 'Malas',
'packing.noBag': 'Sem mala', 'packing.noBag': 'Sem mala',
'packing.totalWeight': 'Peso total', 'packing.totalWeight': 'Peso total',
@@ -1250,6 +1326,13 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'backup.keep.forever': 'Manter para sempre', 'backup.keep.forever': 'Manter para sempre',
// Photos // Photos
'photos.title': 'Fotos',
'photos.subtitle': '{count} fotos para {trip}',
'photos.dropHere': 'Arraste fotos aqui...',
'photos.dropHereActive': 'Arraste fotos aqui',
'photos.captionForAll': 'Legenda (para todos)',
'photos.captionPlaceholder': 'Legenda opcional...',
'photos.addCaption': 'Adicionar legenda...',
'photos.allDays': 'Todos os dias', 'photos.allDays': 'Todos os dias',
'photos.noPhotos': 'Nenhuma foto ainda', 'photos.noPhotos': 'Nenhuma foto ainda',
'photos.uploadHint': 'Envie suas fotos de viagem', 'photos.uploadHint': 'Envie suas fotos de viagem',
@@ -1257,6 +1340,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'photos.linkPlace': 'Vincular lugar', 'photos.linkPlace': 'Vincular lugar',
'photos.noPlace': 'Sem lugar', 'photos.noPlace': 'Sem lugar',
'photos.uploadN': 'Enviar {n} foto(s)', 'photos.uploadN': 'Enviar {n} foto(s)',
'photos.linkDay': 'Vincular dia',
'photos.noDay': 'Nenhum dia',
'photos.dayLabel': 'Dia {number}',
'photos.photoSelected': 'Foto selecionada',
'photos.photosSelected': 'Fotos selecionadas',
'photos.fileTypeHint': 'JPG, PNG, WebP · máx. 10 MB · até 30 fotos',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Restaurar backup?', 'backup.restoreConfirmTitle': 'Restaurar backup?',
@@ -1283,6 +1372,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'planner.routeCalculated': 'Rota calculada', 'planner.routeCalculated': 'Rota calculada',
'planner.routeCalcFailed': 'Não foi possível calcular a rota', 'planner.routeCalcFailed': 'Não foi possível calcular a rota',
'planner.routeError': 'Erro ao calcular a rota', 'planner.routeError': 'Erro ao calcular a rota',
'planner.icsExportFailed': 'Falha ao exportar ICS',
'planner.routeOptimized': 'Rota otimizada', 'planner.routeOptimized': 'Rota otimizada',
'planner.reservationUpdated': 'Reserva atualizada', 'planner.reservationUpdated': 'Reserva atualizada',
'planner.reservationAdded': 'Reserva adicionada', 'planner.reservationAdded': 'Reserva adicionada',
@@ -1438,6 +1528,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Fotos', 'memories.title': 'Fotos',
'memories.notConnected': 'Immich não conectado', 'memories.notConnected': 'Immich não conectado',
'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.', 'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.',
'memories.notConnectedMultipleHint': 'Conecte um destes provedores de fotos: {provider_names} nas Configurações para poder adicionar fotos a esta viagem.',
'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.', 'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.',
'memories.noPhotos': 'Nenhuma foto encontrada', 'memories.noPhotos': 'Nenhuma foto encontrada',
'memories.noPhotosHint': 'Nenhuma foto encontrada no Immich para o período desta viagem.', 'memories.noPhotosHint': 'Nenhuma foto encontrada no Immich para o período desta viagem.',
@@ -1448,23 +1539,32 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Revise suas fotos', 'memories.reviewTitle': 'Revise suas fotos',
'memories.reviewHint': 'Clique nas fotos para excluí-las do compartilhamento.', 'memories.reviewHint': 'Clique nas fotos para excluí-las do compartilhamento.',
'memories.shareCount': 'Compartilhar {count} fotos', 'memories.shareCount': 'Compartilhar {count} fotos',
'memories.immichUrl': 'URL do servidor Immich', 'memories.providerUrl': 'URL do servidor',
'memories.immichApiKey': 'Chave da API', 'memories.providerApiKey': 'Chave de API',
'memories.providerUsername': 'Nome de usuário',
'memories.providerPassword': 'Senha',
'memories.providerOTP': 'Código MFA (se habilitado)',
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Testar conexão', 'memories.testConnection': 'Testar conexão',
'memories.testFirst': 'Teste a conexão primeiro', 'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado', 'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado', 'memories.disconnected': 'Não conectado',
'memories.connectionSuccess': 'Conectado ao Immich', 'memories.connectionSuccess': 'Conectado ao Immich',
'memories.connectionError': 'Não foi possível conectar ao Immich', 'memories.connectionError': 'Não foi possível conectar ao Immich',
'memories.saved': 'Configurações do Immich salvas', 'memories.saved': 'Configurações do {provider_name} salvas',
'memories.providerDisconnectedBanner': 'Sua conexão com {provider_name} foi perdida. Reconecte nas Configurações para ver as fotos.',
'memories.saveError': 'Não foi possível salvar as configurações de {provider_name}',
'memories.addPhotos': 'Adicionar fotos', 'memories.addPhotos': 'Adicionar fotos',
'memories.linkAlbum': 'Vincular álbum', 'memories.linkAlbum': 'Vincular álbum',
'memories.selectAlbum': 'Selecionar álbum do Immich', 'memories.selectAlbum': 'Selecionar álbum do Immich',
'memories.selectAlbumMultiple': 'Selecionar álbum',
'memories.noAlbums': 'Nenhum álbum encontrado', 'memories.noAlbums': 'Nenhum álbum encontrado',
'memories.syncAlbum': 'Sincronizar álbum', 'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular', 'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos', 'memories.photos': 'fotos',
'memories.selectPhotos': 'Selecionar fotos do Immich', 'memories.selectPhotos': 'Selecionar fotos do Immich',
'memories.selectPhotosMultiple': 'Selecionar fotos',
'memories.selectHint': 'Toque nas fotos para selecioná-las.', 'memories.selectHint': 'Toque nas fotos para selecioná-las.',
'memories.selected': 'selecionadas', 'memories.selected': 'selecionadas',
'memories.addSelected': 'Adicionar {count} fotos', 'memories.addSelected': 'Adicionar {count} fotos',
@@ -1482,9 +1582,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Permissions // Permissions
'admin.tabs.permissions': 'Permissões', 'admin.tabs.permissions': 'Permissões',
'admin.tabs.mcpTokens': 'Tokens MCP', 'admin.tabs.mcpTokens': 'Acesso MCP',
'admin.mcpTokens.title': 'Tokens MCP', 'admin.mcpTokens.title': 'Acesso MCP',
'admin.mcpTokens.subtitle': 'Gerenciar tokens de API de todos os usuários', 'admin.mcpTokens.subtitle': 'Gerenciar sessões OAuth e tokens de API de todos os usuários',
'admin.mcpTokens.sectionTitle': 'Tokens de API',
'admin.mcpTokens.owner': 'Proprietário', 'admin.mcpTokens.owner': 'Proprietário',
'admin.mcpTokens.tokenName': 'Nome do Token', 'admin.mcpTokens.tokenName': 'Nome do Token',
'admin.mcpTokens.created': 'Criado', 'admin.mcpTokens.created': 'Criado',
@@ -1496,6 +1597,17 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.mcpTokens.deleteSuccess': 'Token excluído', 'admin.mcpTokens.deleteSuccess': 'Token excluído',
'admin.mcpTokens.deleteError': 'Falha ao excluir token', 'admin.mcpTokens.deleteError': 'Falha ao excluir token',
'admin.mcpTokens.loadError': 'Falha ao carregar tokens', 'admin.mcpTokens.loadError': 'Falha ao carregar tokens',
'admin.oauthSessions.sectionTitle': 'Sessões OAuth',
'admin.oauthSessions.clientName': 'Cliente',
'admin.oauthSessions.owner': 'Proprietário',
'admin.oauthSessions.scopes': 'Permissões',
'admin.oauthSessions.created': 'Criado',
'admin.oauthSessions.empty': 'Nenhuma sessão OAuth ativa',
'admin.oauthSessions.revokeTitle': 'Revogar sessão',
'admin.oauthSessions.revokeMessage': 'Esta sessão OAuth será revogada imediatamente. O cliente perderá o acesso MCP.',
'admin.oauthSessions.revokeSuccess': 'Sessão revogada',
'admin.oauthSessions.revokeError': 'Falha ao revogar sessão',
'admin.oauthSessions.loadError': 'Falha ao carregar sessões OAuth',
'perm.title': 'Configurações de Permissões', 'perm.title': 'Configurações de Permissões',
'perm.subtitle': 'Controle quem pode realizar ações no aplicativo', 'perm.subtitle': 'Controle quem pode realizar ações no aplicativo',
'perm.saved': 'Configurações de permissões salvas', 'perm.saved': 'Configurações de permissões salvas',
@@ -1569,6 +1681,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Marcar como não lido', 'notifications.markUnread': 'Marcar como não lido',
'notifications.delete': 'Excluir', 'notifications.delete': 'Excluir',
'notifications.system': 'Sistema', 'notifications.system': 'Sistema',
'notifications.synologySessionCleared.title': 'Synology Photos desconectado',
'notifications.synologySessionCleared.text': 'Seu servidor ou conta foi alterado — vá para Configurações para testar sua conexão novamente.',
'memories.error.loadAlbums': 'Falha ao carregar álbuns', 'memories.error.loadAlbums': 'Falha ao carregar álbuns',
'memories.error.linkAlbum': 'Falha ao vincular álbum', 'memories.error.linkAlbum': 'Falha ao vincular álbum',
'memories.error.unlinkAlbum': 'Falha ao desvincular álbum', 'memories.error.unlinkAlbum': 'Falha ao desvincular álbum',
@@ -1691,6 +1805,310 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Você tem uma nova notificação', 'notif.generic.text': 'Você tem uma nova notificação',
'notif.dev.unknown_event.title': '[DEV] Evento desconhecido', 'notif.dev.unknown_event.title': '[DEV] Evento desconhecido',
'notif.dev.unknown_event.text': 'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'O tipo de evento "{event}" não está registrado em EVENT_NOTIFICATION_CONFIG',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'agora mesmo',
'common.hoursAgo': 'há {count}h',
'common.daysAgo': 'há {count}d',
'budget.linkedToReservation': 'Vinculado a uma reserva — edite o nome lá',
'packing.saveAsTemplate': 'Salvar como modelo',
'packing.templateName': 'Nome do modelo',
'packing.templateSaved': 'Lista de bagagem salva como modelo',
'memories.notConnectedMultipleHint': 'Conecte qualquer um destes provedores de fotos: {provider_names} em Configurações para poder adicionar fotos a esta viagem.',
'memories.providerUrl': 'URL do servidor',
'memories.providerApiKey': 'Chave da API',
'memories.providerUsername': 'Nome de usuário',
'memories.providerPassword': 'Senha',
'memories.saveError': 'Não foi possível salvar as configurações de {provider_name}',
'memories.saveRouteNotConfigured': 'A rota de salvamento não está configurada para este provedor',
'memories.testRouteNotConfigured': 'A rota de teste não está configurada para este provedor',
'memories.fillRequiredFields': 'Por favor preencha todos os campos obrigatórios',
'memories.selectAlbumMultiple': 'Selecionar álbum',
'memories.selectPhotosMultiple': 'Selecionar fotos',
'journey.title': 'Jornada',
'journey.subtitle': 'Registre suas viagens em tempo real',
'journey.new': 'Nova jornada',
'journey.create': 'Criar',
'journey.titlePlaceholder': 'Para onde você vai?',
'journey.empty': 'Nenhuma jornada ainda',
'journey.emptyHint': 'Comece a documentar sua próxima viagem',
'journey.deleted': 'Jornada excluída',
'journey.createError': 'Não foi possível criar a jornada',
'journey.deleteError': 'Não foi possível excluir a jornada',
'journey.deleteConfirmTitle': 'Excluir',
'journey.deleteConfirmMessage': 'Excluir "{title}"? Isso não pode ser desfeito.',
'journey.deleteConfirmGeneric': 'Tem certeza de que deseja excluir isso?',
'journey.notFound': 'Jornada não encontrada',
'journey.photos': 'Fotos',
'journey.timelineEmpty': 'Nenhuma parada ainda',
'journey.timelineEmptyHint': 'Adicione um check-in ou escreva uma entrada no diário para começar',
'journey.status.draft': 'Rascunho',
'journey.status.active': 'Ativa',
'journey.status.completed': 'Concluída',
'journey.status.upcoming': 'Próxima',
'journey.checkin.add': 'Fazer check-in',
'journey.checkin.namePlaceholder': 'Nome do local',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
'journey.checkin.save': 'Salvar',
'journey.checkin.error': 'Não foi possível salvar o check-in',
'journey.entry.add': 'Diário',
'journey.entry.edit': 'Editar entrada',
'journey.entry.titlePlaceholder': 'Título (opcional)',
'journey.entry.bodyPlaceholder': 'O que aconteceu hoje?',
'journey.entry.save': 'Salvar',
'journey.entry.error': 'Não foi possível salvar a entrada',
'journey.photo.add': 'Foto',
'journey.photo.uploadError': 'Falha no envio',
'journey.share.share': 'Compartilhar',
'journey.share.public': 'Público',
'journey.share.linkCopied': 'Link público copiado',
'journey.share.disabled': 'Compartilhamento público desativado',
'journey.editor.titlePlaceholder': 'Dê um nome a este momento...',
'journey.editor.bodyPlaceholder': 'Conte a história deste dia...',
'journey.editor.placePlaceholder': 'Localização (opcional)',
'journey.editor.tagsPlaceholder': 'Tags: joia escondida, melhor refeição, preciso voltar...',
'journey.visibility.private': 'Privado',
'journey.visibility.shared': 'Compartilhado',
'journey.visibility.public': 'Público',
'journey.emptyState.title': 'Sua história começa aqui',
'journey.emptyState.subtitle': 'Faça check-in em um lugar ou escreva sua primeira entrada no diário',
'journey.frontpage.subtitle': 'Transforme suas viagens em histórias que você nunca vai esquecer',
'journey.frontpage.createJourney': 'Criar jornada',
'journey.frontpage.activeJourney': 'Jornada ativa',
'journey.frontpage.allJourneys': 'Todas as jornadas',
'journey.frontpage.journeys': 'jornadas',
'journey.frontpage.createNew': 'Criar uma nova jornada',
'journey.frontpage.createNewSub': 'Escolha viagens, escreva histórias, compartilhe suas aventuras',
'journey.frontpage.live': 'Ao vivo',
'journey.frontpage.synced': 'Sincronizado',
'journey.frontpage.continueWriting': 'Continuar escrevendo',
'journey.frontpage.updated': 'Atualizado {time}',
'journey.frontpage.suggestionLabel': 'A viagem acabou de terminar',
'journey.frontpage.suggestionText': 'Transforme <strong>{title}</strong> em uma jornada',
'journey.frontpage.dismiss': 'Dispensar',
'journey.frontpage.journeyName': 'Nome da jornada',
'journey.frontpage.namePlaceholder': 'ex. Sudeste Asiático 2026',
'journey.frontpage.selectTrips': 'Selecionar viagens',
'journey.frontpage.tripsSelected': 'viagens selecionadas',
'journey.frontpage.trips': 'viagens',
'journey.frontpage.placesImported': 'lugares serão importados',
'journey.frontpage.places': 'lugares',
'journey.detail.backToJourney': 'Voltar à jornada',
'journey.detail.syncedWithTrips': 'Sincronizado com viagens',
'journey.detail.addEntry': 'Adicionar entrada',
'journey.detail.newEntry': 'Nova entrada',
'journey.detail.editEntry': 'Editar entrada',
'journey.detail.noEntries': 'Nenhuma entrada ainda',
'journey.detail.noEntriesHint': 'Adicione uma viagem para começar com entradas preliminares',
'journey.detail.noPhotos': 'Nenhuma foto ainda',
'journey.detail.noPhotosHint': 'Envie fotos para as entradas ou explore sua biblioteca do Immich/Synology',
'journey.detail.journeyStats': 'Estatísticas da jornada',
'journey.detail.syncedTrips': 'Viagens sincronizadas',
'journey.detail.noTripsLinked': 'Nenhuma viagem vinculada ainda',
'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Ler mais',
'journey.detail.prosCons': 'Prós e contras',
'journey.stats.days': 'Dias',
'journey.stats.cities': 'Cidades',
'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares',
'journey.verdict.lovedIt': 'Adorei',
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.uploadPhotos': 'Enviar fotos',
'journey.editor.fromGallery': 'Da galeria',
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
'journey.editor.writeStory': 'Escreva sua história...',
'journey.editor.prosCons': 'Prós e contras',
'journey.editor.pros': 'Prós',
'journey.editor.cons': 'Contras',
'journey.editor.proPlaceholder': 'Algo ótimo...',
'journey.editor.conPlaceholder': 'Não tão bom...',
'journey.editor.addAnother': 'Adicionar outro',
'journey.editor.date': 'Data',
'journey.editor.location': 'Localização',
'journey.editor.searchLocation': 'Buscar localização...',
'journey.editor.mood': 'Humor',
'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Tornar 1º',
'journey.mood.amazing': 'Incrível',
'journey.mood.good': 'Bom',
'journey.mood.neutral': 'Neutro',
'journey.mood.rough': 'Difícil',
'journey.weather.sunny': 'Ensolarado',
'journey.weather.partly': 'Parcialmente nublado',
'journey.weather.cloudy': 'Nublado',
'journey.weather.rainy': 'Chuvoso',
'journey.weather.stormy': 'Tempestuoso',
'journey.weather.cold': 'Nevando',
'journey.trips.linkTrip': 'Vincular viagem',
'journey.trips.searchTrip': 'Buscar viagem',
'journey.trips.searchPlaceholder': 'Nome da viagem ou destino...',
'journey.trips.noTripsAvailable': 'Nenhuma viagem disponível',
'journey.trips.link': 'Vincular',
'journey.trips.tripLinked': 'Viagem vinculada',
'journey.trips.linkFailed': 'Não foi possível vincular a viagem',
'journey.trips.addTrip': 'Adicionar viagem',
'journey.trips.unlinkTrip': 'Desvincular viagem',
'journey.trips.unlinkMessage': 'Desvincular "{title}"? Todas as entradas e fotos sincronizadas desta viagem serão excluídas permanentemente. Isso não pode ser desfeito.',
'journey.trips.unlink': 'Desvincular',
'journey.trips.tripUnlinked': 'Viagem desvinculada',
'journey.trips.unlinkFailed': 'Não foi possível desvincular a viagem',
'journey.trips.noTripsLinkedSettings': 'Nenhuma viagem vinculada',
'journey.contributors.invite': 'Convidar colaborador',
'journey.contributors.searchUser': 'Buscar usuário',
'journey.contributors.searchPlaceholder': 'Nome de usuário ou e-mail...',
'journey.contributors.noUsers': 'Nenhum usuário encontrado',
'journey.contributors.role': 'Função',
'journey.contributors.added': 'Colaborador adicionado',
'journey.contributors.addFailed': 'Não foi possível adicionar o colaborador',
'journey.share.publicShare': 'Compartilhamento público',
'journey.share.createLink': 'Criar link de compartilhamento',
'journey.share.linkCreated': 'Link de compartilhamento criado',
'journey.share.createFailed': 'Não foi possível criar o link',
'journey.share.copy': 'Copiar',
'journey.share.copied': 'Copiado!',
'journey.share.timeline': 'Linha do tempo',
'journey.share.gallery': 'Galeria',
'journey.share.map': 'Mapa',
'journey.share.removeLink': 'Remover link de compartilhamento',
'journey.share.linkDeleted': 'Link de compartilhamento removido',
'journey.share.deleteFailed': 'Não foi possível excluir',
'journey.share.updateFailed': 'Não foi possível atualizar',
'journey.settings.title': 'Configurações da jornada',
'journey.settings.coverImage': 'Imagem de capa',
'journey.settings.changeCover': 'Alterar capa',
'journey.settings.addCover': 'Adicionar imagem de capa',
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'ex. Tailândia, Vietnã e Camboja',
'journey.settings.delete': 'Excluir',
'journey.settings.deleteJourney': 'Excluir jornada',
'journey.settings.deleteMessage': 'Excluir "{title}"? Todas as entradas e fotos serão perdidas.',
'journey.settings.saved': 'Configurações salvas',
'journey.settings.saveFailed': 'Não foi possível salvar',
'journey.settings.coverUpdated': 'Capa atualizada',
'journey.settings.coverFailed': 'Falha no envio',
'journey.settings.failedToDelete': 'Falha ao excluir',
'journey.entries.deleteTitle': 'Excluir entrada',
'journey.photosUploaded': '{count} fotos enviadas',
'journey.photosAdded': '{count} fotos adicionadas',
'journey.public.notFound': 'Não encontrado',
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
'journey.public.readOnly': 'Somente leitura · Jornada pública',
'journey.public.tagline': 'Kit de recursos e exploração de viagens',
'journey.public.sharedVia': 'Compartilhado via',
'journey.public.madeWith': 'Feito com',
'journey.pdf.journeyBook': 'Livro da jornada',
'journey.pdf.madeWith': 'Feito com TREK',
'journey.pdf.day': 'Dia',
'journey.pdf.theEnd': 'Fim',
'journey.pdf.saveAsPdf': 'Salvar como PDF',
'journey.pdf.pages': 'páginas',
'dashboard.greeting.morning': 'Bom dia,',
'dashboard.greeting.afternoon': 'Boa tarde,',
'dashboard.greeting.evening': 'Boa noite,',
'dashboard.mobile.liveNow': 'Ao vivo agora',
'dashboard.mobile.tripProgress': 'Progresso da viagem',
'dashboard.mobile.daysLeft': '{count} dias restantes',
'dashboard.mobile.places': 'Lugares',
'dashboard.mobile.buddies': 'Companheiros',
'dashboard.mobile.newTrip': 'Nova viagem',
'dashboard.mobile.currency': 'Moeda',
'dashboard.mobile.timezone': 'Fuso horário',
'dashboard.mobile.upcomingTrips': 'Próximas viagens',
'dashboard.mobile.yourTrips': 'Suas viagens',
'dashboard.mobile.trips': 'viagens',
'dashboard.mobile.starts': 'Começa',
'dashboard.mobile.duration': 'Duração',
'dashboard.mobile.day': 'dia',
'dashboard.mobile.days': 'dias',
'dashboard.mobile.ongoing': 'Em andamento',
'dashboard.mobile.startsToday': 'Começa hoje',
'dashboard.mobile.tomorrow': 'Amanhã',
'dashboard.mobile.inDays': 'Em {count} dias',
'dashboard.mobile.inMonths': 'Em {count} meses',
'dashboard.mobile.completed': 'Concluído',
'dashboard.mobile.currencyConverter': 'Conversor de moedas',
'nav.profile': 'Perfil',
'nav.bottomSettings': 'Configurações',
'nav.bottomAdmin': 'Administração',
'nav.bottomLogout': 'Sair',
'nav.bottomAdminBadge': 'Admin',
'dayplan.mobile.addPlace': 'Adicionar lugar',
'dayplan.mobile.searchPlaces': 'Buscar lugares...',
'dayplan.mobile.allAssigned': 'Todos os lugares atribuídos',
'dayplan.mobile.noMatch': 'Sem correspondência',
'dayplan.mobile.createNew': 'Criar novo lugar',
'admin.addons.catalog.journey.name': 'Jornada',
'admin.addons.catalog.journey.description': 'Rastreamento de viagens e diário de viajante com check-ins, fotos e histórias diárias',
// OAuth scope groups
'oauth.scope.group.trips': 'Viagens',
'oauth.scope.group.places': 'Locais',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Bagagem',
'oauth.scope.group.todos': 'Tarefas',
'oauth.scope.group.budget': 'Orçamento',
'oauth.scope.group.reservations': 'Reservas',
'oauth.scope.group.collab': 'Colaboração',
'oauth.scope.group.notifications': 'Notificações',
'oauth.scope.group.vacay': 'Férias',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Clima',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
'oauth.scope.trips:read.description': 'Ler viagens, dias, notas e membros',
'oauth.scope.trips:write.label': 'Editar viagens e itinerários',
'oauth.scope.trips:write.description': 'Criar e atualizar viagens, dias, notas e gerenciar membros',
'oauth.scope.trips:delete.label': 'Excluir viagens',
'oauth.scope.trips:delete.description': 'Excluir viagens permanentemente — esta ação é irreversível',
'oauth.scope.trips:share.label': 'Gerenciar links de compartilhamento',
'oauth.scope.trips:share.description': 'Criar, atualizar e revogar links de compartilhamento públicos',
'oauth.scope.places:read.label': 'Ver locais e dados do mapa',
'oauth.scope.places:read.description': 'Ler locais, atribuições de dias, tags e categorias',
'oauth.scope.places:write.label': 'Gerenciar locais',
'oauth.scope.places:write.description': 'Criar, atualizar e excluir locais, atribuições e tags',
'oauth.scope.atlas:read.label': 'Ver Atlas',
'oauth.scope.atlas:read.description': 'Ler países visitados, regiões e lista de desejos',
'oauth.scope.atlas:write.label': 'Gerenciar Atlas',
'oauth.scope.atlas:write.description': 'Marcar países e regiões como visitados, gerenciar lista de desejos',
'oauth.scope.packing:read.label': 'Ver listas de bagagem',
'oauth.scope.packing:read.description': 'Ler itens, malas e responsáveis por categoria',
'oauth.scope.packing:write.label': 'Gerenciar listas de bagagem',
'oauth.scope.packing:write.description': 'Adicionar, atualizar, excluir, marcar e reordenar itens e malas',
'oauth.scope.todos:read.label': 'Ver listas de tarefas',
'oauth.scope.todos:read.description': 'Ler tarefas da viagem e responsáveis por categoria',
'oauth.scope.todos:write.label': 'Gerenciar listas de tarefas',
'oauth.scope.todos:write.description': 'Criar, atualizar, marcar, excluir e reordenar tarefas',
'oauth.scope.budget:read.label': 'Ver orçamento',
'oauth.scope.budget:read.description': 'Ler itens de orçamento e detalhamento de despesas',
'oauth.scope.budget:write.label': 'Gerenciar orçamento',
'oauth.scope.budget:write.description': 'Criar, atualizar e excluir itens de orçamento',
'oauth.scope.reservations:read.label': 'Ver reservas',
'oauth.scope.reservations:read.description': 'Ler reservas e detalhes de acomodação',
'oauth.scope.reservations:write.label': 'Gerenciar reservas',
'oauth.scope.reservations:write.description': 'Criar, atualizar, excluir e reordenar reservas',
'oauth.scope.collab:read.label': 'Ver colaboração',
'oauth.scope.collab:read.description': 'Ler notas colaborativas, enquetes e mensagens',
'oauth.scope.collab:write.label': 'Gerenciar colaboração',
'oauth.scope.collab:write.description': 'Criar, atualizar e excluir notas, enquetes e mensagens',
'oauth.scope.notifications:read.label': 'Ver notificações',
'oauth.scope.notifications:read.description': 'Ler notificações e contagens não lidas',
'oauth.scope.notifications:write.label': 'Gerenciar notificações',
'oauth.scope.notifications:write.description': 'Marcar notificações como lidas e respondê-las',
'oauth.scope.vacay:read.label': 'Ver planos de férias',
'oauth.scope.vacay:read.description': 'Ler dados de planejamento de férias, entradas e estatísticas',
'oauth.scope.vacay:write.label': 'Gerenciar planos de férias',
'oauth.scope.vacay:write.description': 'Criar e gerenciar entradas de férias, feriados e planos de equipe',
'oauth.scope.geo:read.label': 'Mapas e geocodificação',
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
'oauth.scope.weather:read.label': 'Previsão do tempo',
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
} }
export default br export default br
+424 -10
View File
@@ -8,6 +8,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.loading': 'Načítání...', 'common.loading': 'Načítání...',
'common.import': 'Importovat', 'common.import': 'Importovat',
'common.error': 'Chyba', 'common.error': 'Chyba',
'common.unknownError': 'Neznámá chyba',
'common.tooManyAttempts': 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
'common.back': 'Zpět', 'common.back': 'Zpět',
'common.all': 'Vše', 'common.all': 'Vše',
'common.close': 'Zavřít', 'common.close': 'Zavřít',
@@ -26,6 +28,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Heslo', 'common.password': 'Heslo',
'common.saving': 'Ukládání...', 'common.saving': 'Ukládání...',
'trips.memberRemoved': '{username} odebrán',
'trips.memberRemoveError': 'Odebrání se nezdařilo',
'trips.memberAdded': '{username} přidán',
'trips.memberAddError': 'Přidání se nezdařilo',
'common.expand': 'Rozbalit',
'common.collapse': 'Sbalit',
'common.saved': 'Uloženo', 'common.saved': 'Uloženo',
'trips.reminder': 'Připomínka', 'trips.reminder': 'Připomínka',
'trips.reminderNone': 'Žádná', 'trips.reminderNone': 'Žádná',
@@ -181,6 +189,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'MCP endpoint', 'settings.mcp.endpoint': 'MCP endpoint',
'settings.mcp.clientConfig': 'Konfigurace klienta', 'settings.mcp.clientConfig': 'Konfigurace klienta',
'settings.mcp.clientConfigHint': 'Nahraďte <your_token> API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).', 'settings.mcp.clientConfigHint': 'Nahraďte <your_token> API tokenem ze seznamu níže. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
'settings.mcp.clientConfigHintOAuth': 'Nahraďte <your_client_id> a <your_client_secret> přihlašovacími údaji ze klienta OAuth 2.1, který jste vytvořili výše. mcp-remote při prvním připojení otevře prohlížeč pro dokončení autorizace. Cestu k npx může být nutné upravit pro váš systém (např. C:\\PROGRA~1\\nodejs\\npx.cmd ve Windows).',
'settings.mcp.copy': 'Kopírovat', 'settings.mcp.copy': 'Kopírovat',
'settings.mcp.copied': 'Zkopírováno!', 'settings.mcp.copied': 'Zkopírováno!',
'settings.mcp.apiTokens': 'API tokeny', 'settings.mcp.apiTokens': 'API tokeny',
@@ -202,6 +211,48 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'Nepodařilo se vytvořit token', 'settings.mcp.toast.createError': 'Nepodařilo se vytvořit token',
'settings.mcp.toast.deleted': 'Token smazán', 'settings.mcp.toast.deleted': 'Token smazán',
'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token', 'settings.mcp.toast.deleteError': 'Nepodařilo se smazat token',
'settings.mcp.apiTokensDeprecated': 'API tokeny jsou zastaralé a budou odstraněny v budoucí verzi. Místo toho použijte klienty OAuth 2.1.',
'settings.oauth.clients': 'Klienti OAuth 2.1',
'settings.oauth.clientsHint': 'Zaregistrujte klienty OAuth 2.1, aby se aplikace MCP třetích stran (Claude Web, Cursor atd.) mohly připojit bez statických tokenů.',
'settings.oauth.createClient': 'Nový klient',
'settings.oauth.noClients': 'Žádní klienti OAuth nejsou zaregistrováni.',
'settings.oauth.clientId': 'ID klienta',
'settings.oauth.clientSecret': 'Tajný klíč klienta',
'settings.oauth.deleteClient': 'Smazat klienta',
'settings.oauth.deleteClientMessage': 'Tento klient a všechny aktivní relace budou trvale odstraněny. Jakákoliv aplikace, která ho používá, okamžitě ztratí přístup.',
'settings.oauth.rotateSecret': 'Obnovit tajný klíč',
'settings.oauth.rotateSecretMessage': 'Bude vygenerován nový tajný klíč klienta a všechny stávající relace budou okamžitě zneplatněny. Aktualizujte aplikaci před zavřením tohoto dialogu.',
'settings.oauth.rotateSecretConfirm': 'Obnovit',
'settings.oauth.rotateSecretConfirming': 'Obnovování…',
'settings.oauth.rotateSecretDoneTitle': 'Nový tajný klíč vygenerován',
'settings.oauth.rotateSecretDoneWarning': 'Tento tajný klíč se zobrazí pouze jednou. Zkopírujte ho nyní a aktualizujte aplikaci — všechny předchozí relace byly zneplatněny.',
'settings.oauth.activeSessions': 'Aktivní relace OAuth',
'settings.oauth.sessionScopes': 'Oprávnění',
'settings.oauth.sessionExpires': 'Vyprší',
'settings.oauth.revoke': 'Odvolat',
'settings.oauth.revokeSession': 'Odvolat relaci',
'settings.oauth.revokeSessionMessage': 'Tím se okamžitě odvolá přístup pro tuto relaci OAuth.',
'settings.oauth.modal.createTitle': 'Zaregistrovat klienta OAuth',
'settings.oauth.modal.presets': 'Rychlá nastavení',
'settings.oauth.modal.clientName': 'Název aplikace',
'settings.oauth.modal.clientNamePlaceholder': 'např. Claude Web, Moje MCP aplikace',
'settings.oauth.modal.redirectUris': 'Přesměrovací URI',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Jedno URI na řádek. Vyžadováno HTTPS (localhost vyjmuto). Vyžadována přesná shoda.',
'settings.oauth.modal.scopes': 'Povolená oprávnění',
'settings.oauth.modal.scopesHint': 'list_trips a get_trip_summary jsou vždy dostupné — bez požadovaného oprávnění. Umožňují AI zjistit potřebná ID výletů.',
'settings.oauth.modal.selectAll': 'Vybrat vše',
'settings.oauth.modal.deselectAll': 'Zrušit výběr',
'settings.oauth.modal.creating': 'Registrování…',
'settings.oauth.modal.create': 'Zaregistrovat klienta',
'settings.oauth.modal.createdTitle': 'Klient zaregistrován',
'settings.oauth.modal.createdWarning': 'Tajný klíč klienta se zobrazí pouze jednou. Zkopírujte ho nyní — nelze ho obnovit.',
'settings.oauth.toast.createError': 'Registrace klienta OAuth se nezdařila',
'settings.oauth.toast.deleted': 'Klient OAuth smazán',
'settings.oauth.toast.deleteError': 'Smazání klienta OAuth se nezdařilo',
'settings.oauth.toast.revoked': 'Relace odvolána',
'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
'settings.account': 'Účet', 'settings.account': 'Účet',
'settings.about': 'O aplikaci', 'settings.about': 'O aplikaci',
'settings.about.reportBug': 'Nahlásit chybu', 'settings.about.reportBug': 'Nahlásit chybu',
@@ -274,9 +325,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.none': 'Vypnuto', 'admin.notifications.none': 'Vypnuto',
'admin.notifications.email': 'E-mail (SMTP)', 'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Události oznámení',
'admin.notifications.eventsHint': 'Vyberte, které události spouštějí oznámení pro všechny uživatele.',
'admin.notifications.configureFirst': 'Nejprve nakonfigurujte nastavení SMTP nebo webhooku níže, poté povolte události.',
'admin.notifications.save': 'Uložit nastavení oznámení', 'admin.notifications.save': 'Uložit nastavení oznámení',
'admin.notifications.saved': 'Nastavení oznámení uloženo', 'admin.notifications.saved': 'Nastavení oznámení uloženo',
'admin.notifications.testWebhook': 'Odeslat testovací webhook', 'admin.notifications.testWebhook': 'Odeslat testovací webhook',
@@ -371,6 +419,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.mfaHint': 'Otevřete Google Authenticator, Authy nebo jinou TOTP aplikaci.', 'login.mfaHint': 'Otevřete Google Authenticator, Authy nebo jinou TOTP aplikaci.',
'login.mfaBack': '← Zpět k přihlášení', 'login.mfaBack': '← Zpět k přihlášení',
'login.mfaVerify': 'Ověřit', 'login.mfaVerify': 'Ověřit',
'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou',
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
'login.usernameRequired': 'Uživatelské jméno je povinné',
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
// Registrace (Register) // Registrace (Register)
'register.passwordMismatch': 'Hesla se neshodují', 'register.passwordMismatch': 'Hesla se neshodují',
@@ -449,6 +501,17 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Nastavení', 'admin.tabs.settings': 'Nastavení',
'admin.allowRegistration': 'Povolit registraci', 'admin.allowRegistration': 'Povolit registraci',
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat', 'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)', 'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)',
'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.', 'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.',
'admin.apiKeys': 'API klíče', 'admin.apiKeys': 'API klíče',
@@ -550,9 +613,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.audit.col.details': 'Detaily', 'admin.audit.col.details': 'Detaily',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'MCP tokeny', 'admin.tabs.mcpTokens': 'MCP přístup',
'admin.mcpTokens.title': 'MCP tokeny', 'admin.mcpTokens.title': 'MCP přístup',
'admin.mcpTokens.subtitle': 'Správa API tokenů všech uživatelů', 'admin.mcpTokens.subtitle': 'Správa OAuth relací a API tokenů všech uživatelů',
'admin.mcpTokens.sectionTitle': 'API tokeny',
'admin.mcpTokens.owner': 'Vlastník', 'admin.mcpTokens.owner': 'Vlastník',
'admin.mcpTokens.tokenName': 'Název tokenu', 'admin.mcpTokens.tokenName': 'Název tokenu',
'admin.mcpTokens.created': 'Vytvořen', 'admin.mcpTokens.created': 'Vytvořen',
@@ -564,6 +628,17 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.mcpTokens.deleteSuccess': 'Token smazán', 'admin.mcpTokens.deleteSuccess': 'Token smazán',
'admin.mcpTokens.deleteError': 'Nepodařilo se smazat token', 'admin.mcpTokens.deleteError': 'Nepodařilo se smazat token',
'admin.mcpTokens.loadError': 'Nepodařilo se načíst tokeny', 'admin.mcpTokens.loadError': 'Nepodařilo se načíst tokeny',
'admin.oauthSessions.sectionTitle': 'OAuth relace',
'admin.oauthSessions.clientName': 'Klient',
'admin.oauthSessions.owner': 'Vlastník',
'admin.oauthSessions.scopes': 'Oprávnění',
'admin.oauthSessions.created': 'Vytvořeno',
'admin.oauthSessions.empty': 'Žádné aktivní OAuth relace',
'admin.oauthSessions.revokeTitle': 'Zrušit relaci',
'admin.oauthSessions.revokeMessage': 'Tato OAuth relace bude okamžitě zrušena. Klient ztratí přístup k MCP.',
'admin.oauthSessions.revokeSuccess': 'Relace zrušena',
'admin.oauthSessions.revokeError': 'Nepodařilo se zrušit relaci',
'admin.oauthSessions.loadError': 'Nepodařilo se načíst OAuth relace',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -663,6 +738,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'vacay.companyHolidays': 'Firemní volno', 'vacay.companyHolidays': 'Firemní volno',
'vacay.companyHolidaysHint': 'Povolit označování dnů celofiremního volna', 'vacay.companyHolidaysHint': 'Povolit označování dnů celofiremního volna',
'vacay.companyHolidaysNoDeduct': 'Firemní volno se nezapočítává do nároku na dovolenou.', 'vacay.companyHolidaysNoDeduct': 'Firemní volno se nezapočítává do nároku na dovolenou.',
'vacay.weekStart': 'Týden začíná',
'vacay.weekStartHint': 'Zvolte, zda týden začíná v pondělí nebo v neděli',
'vacay.carryOver': 'Převod dovolené', 'vacay.carryOver': 'Převod dovolené',
'vacay.carryOverHint': 'Automaticky převádět zbývající dny do dalšího roku', 'vacay.carryOverHint': 'Automaticky převádět zbývající dny do dalšího roku',
'vacay.sharing': 'Sdílení', 'vacay.sharing': 'Sdílení',
@@ -872,6 +949,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'inspector.files': 'Soubory', 'inspector.files': 'Soubory',
'inspector.filesCount': '{count} souborů', 'inspector.filesCount': '{count} souborů',
'inspector.removeFromDay': 'Odebrat ze dne', 'inspector.removeFromDay': 'Odebrat ze dne',
'inspector.remove': 'Odstranit',
'inspector.addToDay': 'Přidat ke dni', 'inspector.addToDay': 'Přidat ke dni',
'inspector.confirmedRes': 'Potvrzená rezervace', 'inspector.confirmedRes': 'Potvrzená rezervace',
'inspector.pendingRes': 'Čekající rezervace', 'inspector.pendingRes': 'Čekající rezervace',
@@ -1012,6 +1090,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'budget.totalBudget': 'Celkový rozpočet', 'budget.totalBudget': 'Celkový rozpočet',
'budget.byCategory': 'Podle kategorie', 'budget.byCategory': 'Podle kategorie',
'budget.editTooltip': 'Klikněte pro úpravu', 'budget.editTooltip': 'Klikněte pro úpravu',
'budget.linkedToReservation': 'Propojeno s rezervací — název upravte tam',
'budget.confirm.deleteCategory': 'Opravdu chcete smazat kategorii „{name}” s {count} položkami?', 'budget.confirm.deleteCategory': 'Opravdu chcete smazat kategorii „{name}” s {count} položkami?',
'budget.deleteCategory': 'Smazat kategorii', 'budget.deleteCategory': 'Smazat kategorii',
'budget.perPerson': 'Na osobu', 'budget.perPerson': 'Na osobu',
@@ -1021,9 +1100,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Vyúčtování', 'budget.settlement': 'Vyúčtování',
'budget.settlementInfo': 'Klikněte na avatar člena u rozpočtové položky pro zelené označení to znamená, že zaplatil. Vyúčtování pak ukazuje, kdo komu a kolik dluží.', 'budget.settlementInfo': 'Klikněte na avatar člena u rozpočtové položky pro zelené označení to znamená, že zaplatil. Vyúčtování pak ukazuje, kdo komu a kolik dluží.',
'budget.netBalances': 'Čisté zůstatky', 'budget.netBalances': 'Čisté zůstatky',
'budget.linkedToReservation': 'Propojeno s rezervací — upravte název tam',
// Soubory (Files) // Soubory (Files)
'files.title': 'Soubory', 'files.title': 'Soubory',
'files.pageTitle': 'Soubory a dokumenty',
'files.subtitle': '{count} souborů pro {trip}',
'files.downloadPdf': 'Stáhnout PDF',
'files.count': '{count} souborů', 'files.count': '{count} souborů',
'files.countSingular': '1 soubor', 'files.countSingular': '1 soubor',
'files.uploaded': '{count} nahráno', 'files.uploaded': '{count} nahráno',
@@ -1102,7 +1185,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'packing.menuCheckAll': 'Označit vše', 'packing.menuCheckAll': 'Označit vše',
'packing.menuUncheckAll': 'Odznačit vše', 'packing.menuUncheckAll': 'Odznačit vše',
'packing.menuDeleteCat': 'Smazat kategorii', 'packing.menuDeleteCat': 'Smazat kategorii',
'packing.assignUser': 'Přiřadit uživateli',
'packing.noMembers': 'Žádní členové cesty', 'packing.noMembers': 'Žádní členové cesty',
'packing.addItem': 'Přidat položku', 'packing.addItem': 'Přidat položku',
'packing.addItemPlaceholder': 'Název položky...', 'packing.addItemPlaceholder': 'Název položky...',
@@ -1112,6 +1194,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'packing.template': 'Šablona', 'packing.template': 'Šablona',
'packing.templateApplied': '{count} položek přidáno ze šablony', 'packing.templateApplied': '{count} položek přidáno ze šablony',
'packing.templateError': 'Šablonu se nepodařilo použít', 'packing.templateError': 'Šablonu se nepodařilo použít',
'packing.saveAsTemplate': 'Uložit jako šablonu',
'packing.templateName': 'Název šablony',
'packing.templateSaved': 'Seznam balení uložen jako šablona',
'packing.bags': 'Zavazadla', 'packing.bags': 'Zavazadla',
'packing.noBag': 'Nepřiřazeno', 'packing.noBag': 'Nepřiřazeno',
'packing.totalWeight': 'Celková váha', 'packing.totalWeight': 'Celková váha',
@@ -1267,6 +1352,13 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'backup.keep.forever': 'Uchovávat navždy', 'backup.keep.forever': 'Uchovávat navždy',
// Fotky // Fotky
'photos.title': 'Fotografie',
'photos.subtitle': '{count} fotek pro {trip}',
'photos.dropHere': 'Přetáhněte fotografie sem...',
'photos.dropHereActive': 'Přetáhněte fotografie sem',
'photos.captionForAll': 'Popisek (pro všechny)',
'photos.captionPlaceholder': 'Volitelný popisek...',
'photos.addCaption': 'Přidat popisek...',
'photos.allDays': 'Všechny dny', 'photos.allDays': 'Všechny dny',
'photos.noPhotos': 'Zatím žádné fotky', 'photos.noPhotos': 'Zatím žádné fotky',
'photos.uploadHint': 'Nahrajte své cestovní fotky', 'photos.uploadHint': 'Nahrajte své cestovní fotky',
@@ -1274,6 +1366,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'photos.linkPlace': 'Propojit s místem', 'photos.linkPlace': 'Propojit s místem',
'photos.noPlace': 'Žádné místo', 'photos.noPlace': 'Žádné místo',
'photos.uploadN': 'Nahrát {n} fotek', 'photos.uploadN': 'Nahrát {n} fotek',
'photos.linkDay': 'Propojit den',
'photos.noDay': 'Žádný den',
'photos.dayLabel': 'Den {number}',
'photos.photoSelected': 'Fotografie vybrána',
'photos.photosSelected': 'Fotografie vybrány',
'photos.fileTypeHint': 'JPG, PNG, WebP · max. 10 MB · až 30 fotografií',
// Obnovení zálohy // Obnovení zálohy
'backup.restoreConfirmTitle': 'Obnovit zálohu?', 'backup.restoreConfirmTitle': 'Obnovit zálohu?',
@@ -1300,6 +1398,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'planner.routeCalculated': 'Trasa vypočtena', 'planner.routeCalculated': 'Trasa vypočtena',
'planner.routeCalcFailed': 'Trasu se nepodařilo vypočítat', 'planner.routeCalcFailed': 'Trasu se nepodařilo vypočítat',
'planner.routeError': 'Chyba při výpočtu trasy', 'planner.routeError': 'Chyba při výpočtu trasy',
'planner.icsExportFailed': 'Export ICS se nezdařil',
'planner.routeOptimized': 'Trasa optimalizována', 'planner.routeOptimized': 'Trasa optimalizována',
'planner.reservationUpdated': 'Rezervace aktualizována', 'planner.reservationUpdated': 'Rezervace aktualizována',
'planner.reservationAdded': 'Rezervace přidána', 'planner.reservationAdded': 'Rezervace přidána',
@@ -1385,6 +1484,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Fotky', 'memories.title': 'Fotky',
'memories.notConnected': 'Immich není připojen', 'memories.notConnected': 'Immich není připojen',
'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.', 'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.',
'memories.notConnectedMultipleHint': 'Pro přidání fotek k tomuto výletu připojte v Nastavení jednoho z těchto poskytovatelů fotek: {provider_names}.',
'memories.noDates': 'Přidejte data k cestě pro načtení fotek.', 'memories.noDates': 'Přidejte data k cestě pro načtení fotek.',
'memories.noPhotos': 'Nenalezeny žádné fotky', 'memories.noPhotos': 'Nenalezeny žádné fotky',
'memories.noPhotosHint': 'V Immich nebyly nalezeny žádné fotky pro období této cesty.', 'memories.noPhotosHint': 'V Immich nebyly nalezeny žádné fotky pro období této cesty.',
@@ -1395,23 +1495,32 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Zkontrolujte své fotky', 'memories.reviewTitle': 'Zkontrolujte své fotky',
'memories.reviewHint': 'Klikněte na fotky pro vyloučení ze sdílení.', 'memories.reviewHint': 'Klikněte na fotky pro vyloučení ze sdílení.',
'memories.shareCount': 'Sdílet {count} fotek', 'memories.shareCount': 'Sdílet {count} fotek',
'memories.immichUrl': 'URL serveru Immich', 'memories.providerUrl': 'URL serveru',
'memories.immichApiKey': 'API klíč', 'memories.providerApiKey': 'API klíč',
'memories.providerUsername': 'Uživatelské jméno',
'memories.providerPassword': 'Heslo',
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo',
'memories.testConnection': 'Otestovat připojení', 'memories.testConnection': 'Otestovat připojení',
'memories.testFirst': 'Nejprve otestujte připojení', 'memories.testFirst': 'Nejprve otestujte připojení',
'memories.connected': 'Připojeno', 'memories.connected': 'Připojeno',
'memories.disconnected': 'Nepřipojeno', 'memories.disconnected': 'Nepřipojeno',
'memories.connectionSuccess': 'Připojeno k Immich', 'memories.connectionSuccess': 'Připojeno k Immich',
'memories.connectionError': 'Nepodařilo se připojit k Immich', 'memories.connectionError': 'Nepodařilo se připojit k Immich',
'memories.saved': 'Nastavení Immich uloženo', 'memories.saved': 'Nastavení {provider_name} uloženo',
'memories.providerDisconnectedBanner': 'Vaše připojení k {provider_name} bylo ztraceno. Obnovte připojení v Nastavení pro zobrazení fotek.',
'memories.saveError': 'Nepodařilo se uložit nastavení {provider_name}',
'memories.addPhotos': 'Přidat fotky', 'memories.addPhotos': 'Přidat fotky',
'memories.linkAlbum': 'Propojit album', 'memories.linkAlbum': 'Propojit album',
'memories.selectAlbum': 'Vybrat album z Immich', 'memories.selectAlbum': 'Vybrat album z Immich',
'memories.selectAlbumMultiple': 'Vybrat album',
'memories.noAlbums': 'Žádná alba nenalezena', 'memories.noAlbums': 'Žádná alba nenalezena',
'memories.syncAlbum': 'Synchronizovat album', 'memories.syncAlbum': 'Synchronizovat album',
'memories.unlinkAlbum': 'Odpojit', 'memories.unlinkAlbum': 'Odpojit',
'memories.photos': 'fotek', 'memories.photos': 'fotek',
'memories.selectPhotos': 'Vybrat fotky z Immich', 'memories.selectPhotos': 'Vybrat fotky z Immich',
'memories.selectPhotosMultiple': 'Vybrat fotky',
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.', 'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
'memories.selected': 'vybráno', 'memories.selected': 'vybráno',
'memories.addSelected': 'Přidat {count} fotek', 'memories.addSelected': 'Přidat {count} fotek',
@@ -1572,6 +1681,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Označit jako nepřečtené', 'notifications.markUnread': 'Označit jako nepřečtené',
'notifications.delete': 'Smazat', 'notifications.delete': 'Smazat',
'notifications.system': 'Systém', 'notifications.system': 'Systém',
'notifications.synologySessionCleared.title': 'Synology Photos odpojeno',
'notifications.synologySessionCleared.text': 'Váš server nebo účet se změnil — přejděte do Nastavení a znovu otestujte připojení.',
'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.', 'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.',
'atlas.searchCountry': 'Hledat zemi...', 'atlas.searchCountry': 'Hledat zemi...',
'memories.error.loadAlbums': 'Načtení alb se nezdařilo', 'memories.error.loadAlbums': 'Načtení alb se nezdařilo',
@@ -1696,6 +1807,309 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Máte nové oznámení', 'notif.generic.text': 'Máte nové oznámení',
'notif.dev.unknown_event.title': '[DEV] Neznámá událost', 'notif.dev.unknown_event.title': '[DEV] Neznámá událost',
'notif.dev.unknown_event.text': 'Typ události "{event}" není registrován v EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'Typ události "{event}" není registrován v EVENT_NOTIFICATION_CONFIG',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'právě teď',
'common.hoursAgo': 'před {count} h',
'common.daysAgo': 'před {count} d',
'budget.linkedToReservation': 'Propojeno s rezervací — upravte název tam',
'packing.saveAsTemplate': 'Uložit jako šablonu',
'packing.templateName': 'Název šablony',
'packing.templateSaved': 'Balicí seznam uložen jako šablona',
'memories.notConnectedMultipleHint': 'Připojte některého z těchto poskytovatelů fotek: {provider_names} v Nastavení, abyste mohli přidávat fotky k tomuto výletu.',
'memories.providerUrl': 'URL serveru',
'memories.providerApiKey': 'API klíč',
'memories.providerUsername': 'Uživatelské jméno',
'memories.providerPassword': 'Heslo',
'memories.saveError': 'Nepodařilo se uložit nastavení {provider_name}',
'memories.saveRouteNotConfigured': 'Trasa uložení není nakonfigurována pro tohoto poskytovatele',
'memories.testRouteNotConfigured': 'Testovací trasa není nakonfigurována pro tohoto poskytovatele',
'memories.fillRequiredFields': 'Prosím vyplňte všechna povinná pole',
'memories.selectAlbumMultiple': 'Vybrat album',
'memories.selectPhotosMultiple': 'Vybrat fotky',
'journey.title': 'Cestovní deník',
'journey.subtitle': 'Zaznamenávejte své cesty průběžně',
'journey.new': 'Nový cestovní deník',
'journey.create': 'Vytvořit',
'journey.titlePlaceholder': 'Kam jedete?',
'journey.empty': 'Zatím žádné cestovní deníky',
'journey.emptyHint': 'Začněte dokumentovat svůj další výlet',
'journey.deleted': 'Cestovní deník smazán',
'journey.createError': 'Nepodařilo se vytvořit cestovní deník',
'journey.deleteError': 'Nepodařilo se smazat cestovní deník',
'journey.deleteConfirmTitle': 'Smazat',
'journey.deleteConfirmMessage': 'Smazat „{title}"? Tuto akci nelze vrátit zpět.',
'journey.deleteConfirmGeneric': 'Opravdu to chcete smazat?',
'journey.notFound': 'Cestovní deník nenalezen',
'journey.photos': 'Fotky',
'journey.timelineEmpty': 'Zatím žádné zastávky',
'journey.timelineEmptyHint': 'Přidejte odbavení nebo napište záznam do deníku',
'journey.status.draft': 'Koncept',
'journey.status.active': 'Aktivní',
'journey.status.completed': 'Dokončeno',
'journey.status.upcoming': 'Nadcházející',
'journey.checkin.add': 'Odbavit se',
'journey.checkin.namePlaceholder': 'Název místa',
'journey.checkin.notesPlaceholder': 'Poznámky (volitelné)',
'journey.checkin.save': 'Uložit',
'journey.checkin.error': 'Nepodařilo se uložit odbavení',
'journey.entry.add': 'Deník',
'journey.entry.edit': 'Upravit záznam',
'journey.entry.titlePlaceholder': 'Název (volitelný)',
'journey.entry.bodyPlaceholder': 'Co se dnes stalo?',
'journey.entry.save': 'Uložit',
'journey.entry.error': 'Nepodařilo se uložit záznam',
'journey.photo.add': 'Fotka',
'journey.photo.uploadError': 'Nahrávání selhalo',
'journey.share.share': 'Sdílet',
'journey.share.public': 'Veřejný',
'journey.share.linkCopied': 'Veřejný odkaz zkopírován',
'journey.share.disabled': 'Veřejné sdílení vypnuto',
'journey.editor.titlePlaceholder': 'Pojmenujte tento okamžik...',
'journey.editor.bodyPlaceholder': 'Vyprávějte příběh tohoto dne...',
'journey.editor.placePlaceholder': 'Místo (volitelné)',
'journey.editor.tagsPlaceholder': 'Tagy: skrytý klenot, nejlepší jídlo, musím se vrátit...',
'journey.visibility.private': 'Soukromý',
'journey.visibility.shared': 'Sdílený',
'journey.visibility.public': 'Veřejný',
'journey.emptyState.title': 'Váš příběh začíná zde',
'journey.emptyState.subtitle': 'Odbavte se na místě nebo napište svůj první záznam do deníku',
'journey.frontpage.subtitle': 'Proměňte své cesty v příběhy, na které nikdy nezapomenete',
'journey.frontpage.createJourney': 'Vytvořit cestovní deník',
'journey.frontpage.activeJourney': 'Aktivní cestovní deník',
'journey.frontpage.allJourneys': 'Všechny cestovní deníky',
'journey.frontpage.journeys': 'cestovní deníky',
'journey.frontpage.createNew': 'Vytvořit nový cestovní deník',
'journey.frontpage.createNewSub': 'Vyberte cesty, pište příběhy, sdílejte dobrodružství',
'journey.frontpage.live': 'Živě',
'journey.frontpage.synced': 'Synchronizováno',
'journey.frontpage.continueWriting': 'Pokračovat v psaní',
'journey.frontpage.updated': 'Aktualizováno {time}',
'journey.frontpage.suggestionLabel': 'Cesta právě skončila',
'journey.frontpage.suggestionText': 'Proměňte <strong>{title}</strong> v cestovní deník',
'journey.frontpage.dismiss': 'Zavřít',
'journey.frontpage.journeyName': 'Název cestovního deníku',
'journey.frontpage.namePlaceholder': 'např. Jihovýchodní Asie 2026',
'journey.frontpage.selectTrips': 'Vybrat cesty',
'journey.frontpage.tripsSelected': 'cest vybráno',
'journey.frontpage.trips': 'cesty',
'journey.frontpage.placesImported': 'míst bude importováno',
'journey.frontpage.places': 'místa',
'journey.detail.backToJourney': 'Zpět na cestovní deník',
'journey.detail.syncedWithTrips': 'Synchronizováno s cestami',
'journey.detail.addEntry': 'Přidat záznam',
'journey.detail.newEntry': 'Nový záznam',
'journey.detail.editEntry': 'Upravit záznam',
'journey.detail.noEntries': 'Zatím žádné záznamy',
'journey.detail.noEntriesHint': 'Přidejte cestu pro začátek s kostrovými záznamy',
'journey.detail.noPhotos': 'Zatím žádné fotky',
'journey.detail.noPhotosHint': 'Nahrajte fotky k záznamům nebo procházejte knihovnu Immich/Synology',
'journey.detail.journeyStats': 'Statistiky cesty',
'journey.detail.syncedTrips': 'Synchronizované cesty',
'journey.detail.noTripsLinked': 'Zatím žádné propojené cesty',
'journey.detail.contributors': 'Přispěvatelé',
'journey.detail.readMore': 'Číst dále',
'journey.detail.prosCons': 'Klady a zápory',
'journey.stats.days': 'Dny',
'journey.stats.cities': 'Města',
'journey.stats.entries': 'Záznamy',
'journey.stats.photos': 'Fotky',
'journey.stats.places': 'Místa',
'journey.verdict.lovedIt': 'Skvělé',
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
'journey.synced.places': 'místa',
'journey.synced.synced': 'synchronizováno',
'journey.editor.uploadPhotos': 'Nahrát fotky',
'journey.editor.fromGallery': 'Z galerie',
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
'journey.editor.writeStory': 'Napište svůj příběh...',
'journey.editor.prosCons': 'Klady a zápory',
'journey.editor.pros': 'Klady',
'journey.editor.cons': 'Zápory',
'journey.editor.proPlaceholder': 'Něco skvělého...',
'journey.editor.conPlaceholder': 'Ne tak skvělé...',
'journey.editor.addAnother': 'Přidat další',
'journey.editor.date': 'Datum',
'journey.editor.location': 'Místo',
'journey.editor.searchLocation': 'Hledat místo...',
'journey.editor.mood': 'Nálada',
'journey.editor.weather': 'Počasí',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Nastavit jako 1.',
'journey.mood.amazing': 'Úžasný',
'journey.mood.good': 'Dobrý',
'journey.mood.neutral': 'Neutrální',
'journey.mood.rough': 'Těžký',
'journey.weather.sunny': 'Slunečno',
'journey.weather.partly': 'Polojasno',
'journey.weather.cloudy': 'Zataženo',
'journey.weather.rainy': 'Deštivo',
'journey.weather.stormy': 'Bouřlivo',
'journey.weather.cold': 'Sněžení',
'journey.trips.linkTrip': 'Propojit cestu',
'journey.trips.searchTrip': 'Hledat cestu',
'journey.trips.searchPlaceholder': 'Název cesty nebo cíl...',
'journey.trips.noTripsAvailable': 'Žádné dostupné cesty',
'journey.trips.link': 'Propojit',
'journey.trips.tripLinked': 'Cesta propojena',
'journey.trips.linkFailed': 'Propojení cesty selhalo',
'journey.trips.addTrip': 'Přidat cestu',
'journey.trips.unlinkTrip': 'Odpojit cestu',
'journey.trips.unlinkMessage': 'Odpojit „{title}"? Všechny synchronizované záznamy a fotky z této cesty budou trvale smazány. Tuto akci nelze vrátit zpět.',
'journey.trips.unlink': 'Odpojit',
'journey.trips.tripUnlinked': 'Cesta odpojena',
'journey.trips.unlinkFailed': 'Odpojení cesty selhalo',
'journey.trips.noTripsLinkedSettings': 'Žádné propojené cesty',
'journey.contributors.invite': 'Pozvat přispěvatele',
'journey.contributors.searchUser': 'Hledat uživatele',
'journey.contributors.searchPlaceholder': 'Uživatelské jméno nebo e-mail...',
'journey.contributors.noUsers': 'Žádní uživatelé nenalezeni',
'journey.contributors.role': 'Role',
'journey.contributors.added': 'Přispěvatel přidán',
'journey.contributors.addFailed': 'Přidání přispěvatele selhalo',
'journey.share.publicShare': 'Veřejné sdílení',
'journey.share.createLink': 'Vytvořit odkaz ke sdílení',
'journey.share.linkCreated': 'Odkaz ke sdílení vytvořen',
'journey.share.createFailed': 'Vytvoření odkazu selhalo',
'journey.share.copy': 'Kopírovat',
'journey.share.copied': 'Zkopírováno!',
'journey.share.timeline': 'Časová osa',
'journey.share.gallery': 'Galerie',
'journey.share.map': 'Mapa',
'journey.share.removeLink': 'Odstranit odkaz ke sdílení',
'journey.share.linkDeleted': 'Odkaz ke sdílení smazán',
'journey.share.deleteFailed': 'Smazání selhalo',
'journey.share.updateFailed': 'Aktualizace selhala',
'journey.settings.title': 'Nastavení cestovního deníku',
'journey.settings.coverImage': 'Titulní obrázek',
'journey.settings.changeCover': 'Změnit obal',
'journey.settings.addCover': 'Přidat titulní obrázek',
'journey.settings.name': 'Název',
'journey.settings.subtitle': 'Podtitul',
'journey.settings.subtitlePlaceholder': 'např. Thajsko, Vietnam a Kambodža',
'journey.settings.delete': 'Smazat',
'journey.settings.deleteJourney': 'Smazat cestovní deník',
'journey.settings.deleteMessage': 'Smazat „{title}"? Všechny záznamy a fotky budou ztraceny.',
'journey.settings.saved': 'Nastavení uloženo',
'journey.settings.saveFailed': 'Uložení selhalo',
'journey.settings.coverUpdated': 'Obal aktualizován',
'journey.settings.coverFailed': 'Nahrávání selhalo',
'journey.settings.failedToDelete': 'Smazání se nezdařilo',
'journey.entries.deleteTitle': 'Smazat záznam',
'journey.photosUploaded': '{count} fotografií nahráno',
'journey.photosAdded': '{count} fotografií přidáno',
'journey.public.notFound': 'Nenalezeno',
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
'journey.public.readOnly': 'Pouze ke čtení · Veřejný cestovní deník',
'journey.public.tagline': 'Travel Resource & Exploration Kit',
'journey.public.sharedVia': 'Sdíleno přes',
'journey.public.madeWith': 'Vytvořeno pomocí',
'journey.pdf.journeyBook': 'Cestovní kniha',
'journey.pdf.madeWith': 'Vytvořeno pomocí TREK',
'journey.pdf.day': 'Den',
'journey.pdf.theEnd': 'Konec',
'journey.pdf.saveAsPdf': 'Uložit jako PDF',
'journey.pdf.pages': 'stran',
'dashboard.greeting.morning': 'Dobré ráno,',
'dashboard.greeting.afternoon': 'Dobré odpoledne,',
'dashboard.greeting.evening': 'Dobrý večer,',
'dashboard.mobile.liveNow': 'Živě',
'dashboard.mobile.tripProgress': 'Průběh cesty',
'dashboard.mobile.daysLeft': 'Zbývá {count} dní',
'dashboard.mobile.places': 'Místa',
'dashboard.mobile.buddies': 'Spolucestující',
'dashboard.mobile.newTrip': 'Nová cesta',
'dashboard.mobile.currency': 'Měna',
'dashboard.mobile.timezone': 'Časové pásmo',
'dashboard.mobile.upcomingTrips': 'Nadcházející cesty',
'dashboard.mobile.yourTrips': 'Vaše cesty',
'dashboard.mobile.trips': 'cesty',
'dashboard.mobile.starts': 'Začátek',
'dashboard.mobile.duration': 'Doba trvání',
'dashboard.mobile.day': 'den',
'dashboard.mobile.days': 'dní',
'dashboard.mobile.ongoing': 'Probíhající',
'dashboard.mobile.startsToday': 'Začíná dnes',
'dashboard.mobile.tomorrow': 'Zítra',
'dashboard.mobile.inDays': 'Za {count} dní',
'dashboard.mobile.inMonths': 'Za {count} měsíců',
'dashboard.mobile.completed': 'Dokončeno',
'dashboard.mobile.currencyConverter': 'Převodník měn',
'nav.profile': 'Profil',
'nav.bottomSettings': 'Nastavení',
'nav.bottomAdmin': 'Nastavení správce',
'nav.bottomLogout': 'Odhlásit se',
'nav.bottomAdminBadge': 'Správce',
'dayplan.mobile.addPlace': 'Přidat místo',
'dayplan.mobile.searchPlaces': 'Hledat místa...',
'dayplan.mobile.allAssigned': 'Všechna místa přiřazena',
'dayplan.mobile.noMatch': 'Žádná shoda',
'dayplan.mobile.createNew': 'Vytvořit nové místo',
'admin.addons.catalog.journey.name': 'Cestovní deník',
'admin.addons.catalog.journey.description': 'Sledování cest a cestovní deník s odbaveními, fotkami a denními příběhy',
// OAuth scope groups
'oauth.scope.group.trips': 'Výlety',
'oauth.scope.group.places': 'Místa',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Balení',
'oauth.scope.group.todos': 'Úkoly',
'oauth.scope.group.budget': 'Rozpočet',
'oauth.scope.group.reservations': 'Rezervace',
'oauth.scope.group.collab': 'Spolupráce',
'oauth.scope.group.notifications': 'Oznámení',
'oauth.scope.group.vacay': 'Dovolená',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Počasí',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Zobrazit výlety a itineráře',
'oauth.scope.trips:read.description': 'Číst výlety, dny, poznámky a členy',
'oauth.scope.trips:write.label': 'Upravit výlety a itineráře',
'oauth.scope.trips:write.description': 'Vytvářet a aktualizovat výlety, dny, poznámky a spravovat členy',
'oauth.scope.trips:delete.label': 'Mazat výlety',
'oauth.scope.trips:delete.description': 'Trvale smazat celé výlety — tato akce je nevratná',
'oauth.scope.trips:share.label': 'Spravovat sdílené odkazy',
'oauth.scope.trips:share.description': 'Vytvářet, aktualizovat a rušit veřejné sdílené odkazy',
'oauth.scope.places:read.label': 'Zobrazit místa a mapová data',
'oauth.scope.places:read.description': 'Číst místa, denní přiřazení, štítky a kategorie',
'oauth.scope.places:write.label': 'Spravovat místa',
'oauth.scope.places:write.description': 'Vytvářet, aktualizovat a mazat místa, přiřazení a štítky',
'oauth.scope.atlas:read.label': 'Zobrazit Atlas',
'oauth.scope.atlas:read.description': 'Číst navštívené země, regiony a seznam přání',
'oauth.scope.atlas:write.label': 'Spravovat Atlas',
'oauth.scope.atlas:write.description': 'Označovat navštívené země a regiony, spravovat seznam přání',
'oauth.scope.packing:read.label': 'Zobrazit seznamy balení',
'oauth.scope.packing:read.description': 'Číst položky, tašky a přiřazení kategorií',
'oauth.scope.packing:write.label': 'Spravovat seznamy balení',
'oauth.scope.packing:write.description': 'Přidávat, aktualizovat, mazat, označovat a řadit položky a tašky',
'oauth.scope.todos:read.label': 'Zobrazit seznamy úkolů',
'oauth.scope.todos:read.description': 'Číst úkoly výletu a přiřazení kategorií',
'oauth.scope.todos:write.label': 'Spravovat seznamy úkolů',
'oauth.scope.todos:write.description': 'Vytvářet, aktualizovat, označovat, mazat a řadit úkoly',
'oauth.scope.budget:read.label': 'Zobrazit rozpočet',
'oauth.scope.budget:read.description': 'Číst položky rozpočtu a přehled výdajů',
'oauth.scope.budget:write.label': 'Spravovat rozpočet',
'oauth.scope.budget:write.description': 'Vytvářet, aktualizovat a mazat položky rozpočtu',
'oauth.scope.reservations:read.label': 'Zobrazit rezervace',
'oauth.scope.reservations:read.description': 'Číst rezervace a podrobnosti ubytování',
'oauth.scope.reservations:write.label': 'Spravovat rezervace',
'oauth.scope.reservations:write.description': 'Vytvářet, aktualizovat, mazat a řadit rezervace',
'oauth.scope.collab:read.label': 'Zobrazit spolupráci',
'oauth.scope.collab:read.description': 'Číst poznámky, ankety a zprávy spolupráce',
'oauth.scope.collab:write.label': 'Spravovat spolupráci',
'oauth.scope.collab:write.description': 'Vytvářet, aktualizovat a mazat poznámky, ankety a zprávy',
'oauth.scope.notifications:read.label': 'Zobrazit oznámení',
'oauth.scope.notifications:read.description': 'Číst oznámení v aplikaci a počty nepřečtených',
'oauth.scope.notifications:write.label': 'Spravovat oznámení',
'oauth.scope.notifications:write.description': 'Označovat oznámení jako přečtená a reagovat na ně',
'oauth.scope.vacay:read.label': 'Zobrazit plány dovolené',
'oauth.scope.vacay:read.description': 'Číst data plánování dovolené, záznamy a statistiky',
'oauth.scope.vacay:write.label': 'Spravovat plány dovolené',
'oauth.scope.vacay:write.description': 'Vytvářet a spravovat záznamy dovolené, svátky a týmové plány',
'oauth.scope.geo:read.label': 'Mapy a geokódování',
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
'oauth.scope.weather:read.label': 'Předpovědi počasí',
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
} }
export default cs export default cs
+427 -19
View File
@@ -8,6 +8,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.loading': 'Laden...', 'common.loading': 'Laden...',
'common.import': 'Importieren', 'common.import': 'Importieren',
'common.error': 'Fehler', 'common.error': 'Fehler',
'common.unknownError': 'Unbekannter Fehler',
'common.tooManyAttempts': 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
'common.back': 'Zurück', 'common.back': 'Zurück',
'common.all': 'Alle', 'common.all': 'Alle',
'common.close': 'Schließen', 'common.close': 'Schließen',
@@ -26,12 +28,21 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-Mail', 'common.email': 'E-Mail',
'common.password': 'Passwort', 'common.password': 'Passwort',
'common.saving': 'Speichern...', 'common.saving': 'Speichern...',
'common.expand': 'Erweitern',
'common.collapse': 'Einklappen',
'common.justNow': 'gerade eben',
'common.hoursAgo': 'vor {count}h',
'common.daysAgo': 'vor {count}T',
'common.saved': 'Gespeichert', 'common.saved': 'Gespeichert',
'trips.reminder': 'Erinnerung', 'trips.reminder': 'Erinnerung',
'trips.reminderNone': 'Keine', 'trips.reminderNone': 'Keine',
'trips.reminderDay': 'Tag', 'trips.reminderDay': 'Tag',
'trips.reminderDays': 'Tage', 'trips.reminderDays': 'Tage',
'trips.reminderCustom': 'Benutzerdefiniert', 'trips.reminderCustom': 'Benutzerdefiniert',
'trips.memberRemoved': '{username} entfernt',
'trips.memberRemoveError': 'Entfernen fehlgeschlagen',
'trips.memberAdded': '{username} hinzugefügt',
'trips.memberAddError': 'Hinzufügen fehlgeschlagen',
'trips.reminderDaysBefore': 'Tage vor Abreise', 'trips.reminderDaysBefore': 'Tage vor Abreise',
'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.', 'trips.reminderDisabledHint': 'Reiseerinnerungen sind deaktiviert. Aktivieren Sie sie unter Admin > Einstellungen > Benachrichtigungen.',
'common.update': 'Aktualisieren', 'common.update': 'Aktualisieren',
@@ -179,9 +190,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.none': 'Deaktiviert', 'admin.notifications.none': 'Deaktiviert',
'admin.notifications.email': 'E-Mail (SMTP)', 'admin.notifications.email': 'E-Mail (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Benachrichtigungsereignisse',
'admin.notifications.eventsHint': 'Wähle, welche Ereignisse Benachrichtigungen für alle Benutzer auslösen.',
'admin.notifications.configureFirst': 'Konfiguriere zuerst die SMTP- oder Webhook-Einstellungen unten, dann aktiviere die Events.',
'admin.notifications.save': 'Benachrichtigungseinstellungen speichern', 'admin.notifications.save': 'Benachrichtigungseinstellungen speichern',
'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert', 'admin.notifications.saved': 'Benachrichtigungseinstellungen gespeichert',
'admin.notifications.testWebhook': 'Test-Webhook senden', 'admin.notifications.testWebhook': 'Test-Webhook senden',
@@ -228,6 +236,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'MCP-Endpunkt', 'settings.mcp.endpoint': 'MCP-Endpunkt',
'settings.mcp.clientConfig': 'Client-Konfiguration', 'settings.mcp.clientConfig': 'Client-Konfiguration',
'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).', 'settings.mcp.clientConfigHint': 'Ersetze <your_token> durch ein API-Token aus der Liste unten. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
'settings.mcp.clientConfigHintOAuth': 'Ersetze <your_client_id> und <your_client_secret> durch die Zugangsdaten des oben erstellten OAuth 2.1-Clients. mcp-remote öffnet beim ersten Verbindungsaufbau deinen Browser zur Autorisierung. Der Pfad zu npx muss ggf. für dein System angepasst werden (z. B. C:\\PROGRA~1\\nodejs\\npx.cmd unter Windows).',
'settings.mcp.copy': 'Kopieren', 'settings.mcp.copy': 'Kopieren',
'settings.mcp.copied': 'Kopiert!', 'settings.mcp.copied': 'Kopiert!',
'settings.mcp.apiTokens': 'API-Tokens', 'settings.mcp.apiTokens': 'API-Tokens',
@@ -249,6 +258,48 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden', 'settings.mcp.toast.createError': 'Token konnte nicht erstellt werden',
'settings.mcp.toast.deleted': 'Token gelöscht', 'settings.mcp.toast.deleted': 'Token gelöscht',
'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden', 'settings.mcp.toast.deleteError': 'Token konnte nicht gelöscht werden',
'settings.mcp.apiTokensDeprecated': 'API-Tokens sind veraltet und werden in einer zukünftigen Version entfernt. Bitte verwende stattdessen OAuth 2.1-Clients.',
'settings.oauth.clients': 'OAuth 2.1-Clients',
'settings.oauth.clientsHint': 'Registriere OAuth 2.1-Clients, damit externe MCP-Anwendungen (Claude Web, Cursor usw.) sich ohne statische Tokens verbinden können.',
'settings.oauth.createClient': 'Neuer Client',
'settings.oauth.noClients': 'Keine OAuth-Clients registriert.',
'settings.oauth.clientId': 'Client-ID',
'settings.oauth.clientSecret': 'Client-Secret',
'settings.oauth.deleteClient': 'Client löschen',
'settings.oauth.deleteClientMessage': 'Dieser Client und alle aktiven Sessions werden dauerhaft entfernt. Jede Anwendung, die ihn nutzt, verliert sofort den Zugriff.',
'settings.oauth.rotateSecret': 'Secret erneuern',
'settings.oauth.rotateSecretMessage': 'Ein neues Client-Secret wird generiert und alle bestehenden Sessions werden sofort ungültig. Aktualisiere deine Anwendung, bevor du diesen Dialog schließt.',
'settings.oauth.rotateSecretConfirm': 'Erneuern',
'settings.oauth.rotateSecretConfirming': 'Wird erneuert…',
'settings.oauth.rotateSecretDoneTitle': 'Neues Secret generiert',
'settings.oauth.rotateSecretDoneWarning': 'Dieses Secret wird nur einmal angezeigt. Kopiere es jetzt und aktualisiere deine Anwendung — alle vorherigen Sessions wurden ungültig gemacht.',
'settings.oauth.activeSessions': 'Aktive OAuth-Sessions',
'settings.oauth.sessionScopes': 'Berechtigungen',
'settings.oauth.sessionExpires': 'Läuft ab',
'settings.oauth.revoke': 'Widerrufen',
'settings.oauth.revokeSession': 'Session widerrufen',
'settings.oauth.revokeSessionMessage': 'Dadurch wird der Zugriff für diese OAuth-Session sofort widerrufen.',
'settings.oauth.modal.createTitle': 'OAuth-Client registrieren',
'settings.oauth.modal.presets': 'Schnellvorlagen',
'settings.oauth.modal.clientName': 'Anwendungsname',
'settings.oauth.modal.clientNamePlaceholder': 'z. B. Claude Web, Meine MCP-App',
'settings.oauth.modal.redirectUris': 'Redirect-URIs',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Eine URI pro Zeile. HTTPS erforderlich (localhost ausgenommen). Exakte Übereinstimmung erforderlich.',
'settings.oauth.modal.scopes': 'Erlaubte Berechtigungen',
'settings.oauth.modal.scopesHint': 'list_trips und get_trip_summary sind immer verfügbar — keine Berechtigung nötig. Sie helfen der KI, Trip-IDs zu ermitteln.',
'settings.oauth.modal.selectAll': 'Alle auswählen',
'settings.oauth.modal.deselectAll': 'Alle abwählen',
'settings.oauth.modal.creating': 'Wird registriert…',
'settings.oauth.modal.create': 'Client registrieren',
'settings.oauth.modal.createdTitle': 'Client registriert',
'settings.oauth.modal.createdWarning': 'Das Client-Secret wird nur einmal angezeigt. Kopiere es jetzt — es kann nicht wiederhergestellt werden.',
'settings.oauth.toast.createError': 'OAuth-Client konnte nicht registriert werden',
'settings.oauth.toast.deleted': 'OAuth-Client gelöscht',
'settings.oauth.toast.deleteError': 'OAuth-Client konnte nicht gelöscht werden',
'settings.oauth.toast.revoked': 'Session widerrufen',
'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
'settings.account': 'Konto', 'settings.account': 'Konto',
'settings.about': 'Über', 'settings.about': 'Über',
'settings.about.reportBug': 'Bug melden', 'settings.about.reportBug': 'Bug melden',
@@ -371,6 +422,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.', 'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.',
'login.mfaBack': '← Zurück zur Anmeldung', 'login.mfaBack': '← Zurück zur Anmeldung',
'login.mfaVerify': 'Bestätigen', 'login.mfaVerify': 'Bestätigen',
'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink',
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
'login.usernameRequired': 'Benutzername ist erforderlich',
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
// Register // Register
'register.passwordMismatch': 'Passwörter stimmen nicht überein', 'register.passwordMismatch': 'Passwörter stimmen nicht überein',
@@ -450,15 +505,26 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Einstellungen', 'admin.tabs.settings': 'Einstellungen',
'admin.allowRegistration': 'Registrierung erlauben', 'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren', 'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen', 'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen',
'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.', 'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.',
'admin.apiKeys': 'API-Schlüssel', 'admin.apiKeys': 'API-Schlüssel',
'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.', 'admin.apiKeysHint': 'Optional. Aktiviert erweiterte Ortsdaten wie Fotos und Wetter.',
'admin.mapsKey': 'Google Maps API Key', 'admin.mapsKey': 'Google Maps API-Schlüssel',
'admin.mapsKeyHint': 'Für Ortsuche benötigt. Erstellen unter console.cloud.google.com', 'admin.mapsKeyHint': 'Für Ortsuche benötigt. Erstellen unter console.cloud.google.com',
'admin.mapsKeyHintLong': 'Ohne API Key wird OpenStreetMap für die Ortssuche genutzt. Mit Google API Key können zusätzlich Bilder, Bewertungen und Öffnungszeiten geladen werden. Erstellen unter console.cloud.google.com.', 'admin.mapsKeyHintLong': 'Ohne API Key wird OpenStreetMap für die Ortssuche genutzt. Mit Google API Key können zusätzlich Bilder, Bewertungen und Öffnungszeiten geladen werden. Erstellen unter console.cloud.google.com.',
'admin.recommended': 'Empfohlen', 'admin.recommended': 'Empfohlen',
'admin.weatherKey': 'OpenWeatherMap API Key', 'admin.weatherKey': 'OpenWeatherMap API-Schlüssel',
'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org', 'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org',
'admin.validateKey': 'Test', 'admin.validateKey': 'Test',
'admin.keyValid': 'Verbunden', 'admin.keyValid': 'Verbunden',
@@ -526,7 +592,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.', 'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.',
'admin.addons.enabled': 'Aktiviert', 'admin.addons.enabled': 'Aktiviert',
'admin.addons.disabled': 'Deaktiviert', 'admin.addons.disabled': 'Deaktiviert',
'admin.addons.type.trip': 'Trip', 'admin.addons.type.trip': 'Reise',
'admin.addons.type.global': 'Global', 'admin.addons.type.global': 'Global',
'admin.addons.type.integration': 'Integration', 'admin.addons.type.integration': 'Integration',
'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips', 'admin.addons.tripHint': 'Verfügbar als Tab innerhalb jedes Trips',
@@ -548,9 +614,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.', 'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'MCP-Tokens', 'admin.tabs.mcpTokens': 'MCP-Zugang',
'admin.mcpTokens.title': 'MCP-Tokens', 'admin.mcpTokens.title': 'MCP-Zugang',
'admin.mcpTokens.subtitle': 'API-Tokens aller Benutzer verwalten', 'admin.mcpTokens.subtitle': 'OAuth-Sitzungen und API-Tokens aller Benutzer verwalten',
'admin.mcpTokens.sectionTitle': 'API-Tokens',
'admin.mcpTokens.owner': 'Besitzer', 'admin.mcpTokens.owner': 'Besitzer',
'admin.mcpTokens.tokenName': 'Token-Name', 'admin.mcpTokens.tokenName': 'Token-Name',
'admin.mcpTokens.created': 'Erstellt', 'admin.mcpTokens.created': 'Erstellt',
@@ -562,6 +629,17 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.mcpTokens.deleteSuccess': 'Token gelöscht', 'admin.mcpTokens.deleteSuccess': 'Token gelöscht',
'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden', 'admin.mcpTokens.deleteError': 'Token konnte nicht gelöscht werden',
'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden', 'admin.mcpTokens.loadError': 'Tokens konnten nicht geladen werden',
'admin.oauthSessions.sectionTitle': 'OAuth-Sitzungen',
'admin.oauthSessions.clientName': 'Client',
'admin.oauthSessions.owner': 'Besitzer',
'admin.oauthSessions.scopes': 'Berechtigungen',
'admin.oauthSessions.created': 'Erstellt',
'admin.oauthSessions.empty': 'Keine aktiven OAuth-Sitzungen',
'admin.oauthSessions.revokeTitle': 'Sitzung widerrufen',
'admin.oauthSessions.revokeMessage': 'Diese OAuth-Sitzung wird sofort widerrufen. Der Client verliert den MCP-Zugang.',
'admin.oauthSessions.revokeSuccess': 'Sitzung widerrufen',
'admin.oauthSessions.revokeError': 'Sitzung konnte nicht widerrufen werden',
'admin.oauthSessions.loadError': 'OAuth-Sitzungen konnten nicht geladen werden',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -661,6 +739,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'vacay.companyHolidays': 'Betriebsferien', 'vacay.companyHolidays': 'Betriebsferien',
'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen', 'vacay.companyHolidaysHint': 'Erlaubt das Markieren von unternehmensweiten Feiertagen',
'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.', 'vacay.companyHolidaysNoDeduct': 'Betriebsferien werden nicht vom Urlaubskontingent abgezogen.',
'vacay.weekStart': 'Woche beginnt am',
'vacay.weekStartHint': 'Wähle ob die Kalenderwoche am Montag oder Sonntag beginnt',
'vacay.carryOver': 'Urlaubsmitnahme', 'vacay.carryOver': 'Urlaubsmitnahme',
'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen', 'vacay.carryOverHint': 'Resturlaub automatisch ins Folgejahr übertragen',
'vacay.sharing': 'Teilen', 'vacay.sharing': 'Teilen',
@@ -718,7 +798,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'atlas.addToBucketHint': 'Als Wunschziel speichern', 'atlas.addToBucketHint': 'Als Wunschziel speichern',
'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?', 'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?',
'atlas.statsTab': 'Statistik', 'atlas.statsTab': 'Statistik',
'atlas.bucketTab': 'Bucket List', 'atlas.bucketTab': 'Wunschliste',
'atlas.addBucket': 'Zur Bucket List hinzufügen', 'atlas.addBucket': 'Zur Bucket List hinzufügen',
'atlas.bucketNotesPlaceholder': 'Notizen (optional)', 'atlas.bucketNotesPlaceholder': 'Notizen (optional)',
'atlas.bucketEmpty': 'Deine Bucket List ist leer', 'atlas.bucketEmpty': 'Deine Bucket List ist leer',
@@ -731,7 +811,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'atlas.lastTrip': 'Letzter Trip', 'atlas.lastTrip': 'Letzter Trip',
'atlas.nextTrip': 'Nächster Trip', 'atlas.nextTrip': 'Nächster Trip',
'atlas.daysLeft': 'Tage', 'atlas.daysLeft': 'Tage',
'atlas.streak': 'Streak', 'atlas.streak': 'Serie',
'atlas.years': 'Jahre', 'atlas.years': 'Jahre',
'atlas.yearInRow': 'Jahr in Folge', 'atlas.yearInRow': 'Jahr in Folge',
'atlas.yearsInRow': 'Jahre in Folge', 'atlas.yearsInRow': 'Jahre in Folge',
@@ -847,7 +927,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'places.noCategory': 'Keine Kategorie', 'places.noCategory': 'Keine Kategorie',
'places.categoryNamePlaceholder': 'Kategoriename', 'places.categoryNamePlaceholder': 'Kategoriename',
'places.formTime': 'Uhrzeit', 'places.formTime': 'Uhrzeit',
'places.startTime': 'Start', 'places.startTime': 'Startzeit',
'places.endTime': 'Ende', 'places.endTime': 'Ende',
'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit', 'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
'places.timeCollision': 'Zeitliche Überschneidung mit:', 'places.timeCollision': 'Zeitliche Überschneidung mit:',
@@ -870,6 +950,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'inspector.files': 'Dateien', 'inspector.files': 'Dateien',
'inspector.filesCount': '{count} Dateien', 'inspector.filesCount': '{count} Dateien',
'inspector.removeFromDay': 'Vom Tag entfernen', 'inspector.removeFromDay': 'Vom Tag entfernen',
'inspector.remove': 'Entfernen',
'inspector.addToDay': 'Zum Tag hinzufügen', 'inspector.addToDay': 'Zum Tag hinzufügen',
'inspector.confirmedRes': 'Bestätigte Reservierung', 'inspector.confirmedRes': 'Bestätigte Reservierung',
'inspector.pendingRes': 'Ausstehende Reservierung', 'inspector.pendingRes': 'Ausstehende Reservierung',
@@ -902,7 +983,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)', 'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
'reservations.notes': 'Notizen', 'reservations.notes': 'Notizen',
'reservations.notesPlaceholder': 'Zusätzliche Notizen...', 'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
'reservations.meta.airline': 'Airline', 'reservations.meta.airline': 'Fluggesellschaft',
'reservations.meta.flightNumber': 'Flugnr.', 'reservations.meta.flightNumber': 'Flugnr.',
'reservations.meta.from': 'Von', 'reservations.meta.from': 'Von',
'reservations.meta.to': 'Nach', 'reservations.meta.to': 'Nach',
@@ -1023,6 +1104,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Files // Files
'files.title': 'Dateien', 'files.title': 'Dateien',
'files.pageTitle': 'Dateien & Dokumente',
'files.subtitle': '{count} Dateien für {trip}',
'files.downloadPdf': 'PDF herunterladen',
'files.count': '{count} Dateien', 'files.count': '{count} Dateien',
'files.countSingular': '1 Datei', 'files.countSingular': '1 Datei',
'files.uploaded': '{count} hochgeladen', 'files.uploaded': '{count} hochgeladen',
@@ -1113,7 +1197,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'packing.saveAsTemplate': 'Als Vorlage speichern', 'packing.saveAsTemplate': 'Als Vorlage speichern',
'packing.templateName': 'Vorlagenname', 'packing.templateName': 'Vorlagenname',
'packing.templateSaved': 'Packliste als Vorlage gespeichert', 'packing.templateSaved': 'Packliste als Vorlage gespeichert',
'packing.assignUser': 'Person zuweisen',
'packing.bags': 'Gepäck', 'packing.bags': 'Gepäck',
'packing.noBag': 'Nicht zugeordnet', 'packing.noBag': 'Nicht zugeordnet',
'packing.totalWeight': 'Gesamtgewicht', 'packing.totalWeight': 'Gesamtgewicht',
@@ -1269,6 +1352,13 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'backup.keep.forever': 'Immer behalten', 'backup.keep.forever': 'Immer behalten',
// Photos // Photos
'photos.title': 'Fotos',
'photos.subtitle': '{count} Fotos für {trip}',
'photos.dropHere': 'Fotos hier ablegen...',
'photos.dropHereActive': 'Fotos hier ablegen',
'photos.captionForAll': 'Beschriftung (für alle)',
'photos.captionPlaceholder': 'Optionale Beschriftung...',
'photos.addCaption': 'Beschriftung hinzufügen...',
'photos.allDays': 'Alle Tage', 'photos.allDays': 'Alle Tage',
'photos.noPhotos': 'Noch keine Fotos', 'photos.noPhotos': 'Noch keine Fotos',
'photos.uploadHint': 'Lade deine Reisefotos hoch', 'photos.uploadHint': 'Lade deine Reisefotos hoch',
@@ -1276,6 +1366,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'photos.linkPlace': 'Ort verknüpfen', 'photos.linkPlace': 'Ort verknüpfen',
'photos.noPlace': 'Kein Ort', 'photos.noPlace': 'Kein Ort',
'photos.uploadN': '{n} Foto(s) hochladen', 'photos.uploadN': '{n} Foto(s) hochladen',
'photos.linkDay': 'Tag verknüpfen',
'photos.noDay': 'Kein Tag',
'photos.dayLabel': 'Tag {number}',
'photos.photoSelected': 'Foto ausgewählt',
'photos.photosSelected': 'Fotos ausgewählt',
'photos.fileTypeHint': 'JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Backup wiederherstellen?', 'backup.restoreConfirmTitle': 'Backup wiederherstellen?',
@@ -1302,6 +1398,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'planner.routeCalculated': 'Route berechnet', 'planner.routeCalculated': 'Route berechnet',
'planner.routeCalcFailed': 'Route konnte nicht berechnet werden', 'planner.routeCalcFailed': 'Route konnte nicht berechnet werden',
'planner.routeError': 'Fehler bei der Routenberechnung', 'planner.routeError': 'Fehler bei der Routenberechnung',
'planner.icsExportFailed': 'ICS-Export fehlgeschlagen',
'planner.routeOptimized': 'Route optimiert', 'planner.routeOptimized': 'Route optimiert',
'planner.reservationUpdated': 'Reservierung aktualisiert', 'planner.reservationUpdated': 'Reservierung aktualisiert',
'planner.reservationAdded': 'Reservierung hinzugefügt', 'planner.reservationAdded': 'Reservierung hinzugefügt',
@@ -1387,6 +1484,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Fotos', 'memories.title': 'Fotos',
'memories.notConnected': 'Immich nicht verbunden', 'memories.notConnected': 'Immich nicht verbunden',
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.', 'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
'memories.notConnectedMultipleHint': 'Verbinde einen dieser Fotoanbieter: {provider_names} in den Einstellungen, um Fotos zu dieser Reise hinzufügen zu können.',
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.', 'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
'memories.noPhotos': 'Keine Fotos gefunden', 'memories.noPhotos': 'Keine Fotos gefunden',
'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.', 'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
@@ -1397,23 +1495,35 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Deine Fotos prüfen', 'memories.reviewTitle': 'Deine Fotos prüfen',
'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.', 'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.',
'memories.shareCount': '{count} Fotos teilen', 'memories.shareCount': '{count} Fotos teilen',
'memories.immichUrl': 'Immich Server URL', 'memories.providerUrl': 'Server-URL',
'memories.immichApiKey': 'API-Schlüssel', 'memories.providerApiKey': 'API-Schlüssel',
'memories.providerUsername': 'Benutzername',
'memories.providerPassword': 'Passwort',
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo',
'memories.testConnection': 'Verbindung testen', 'memories.testConnection': 'Verbindung testen',
'memories.testFirst': 'Verbindung zuerst testen', 'memories.testFirst': 'Verbindung zuerst testen',
'memories.connected': 'Verbunden', 'memories.connected': 'Verbunden',
'memories.disconnected': 'Nicht verbunden', 'memories.disconnected': 'Nicht verbunden',
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt', 'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen', 'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
'memories.saved': 'Immich-Einstellungen gespeichert', 'memories.saved': '{provider_name}-Einstellungen gespeichert',
'memories.providerDisconnectedBanner': 'Deine {provider_name}-Verbindung wurde getrennt. Verbinde erneut in den Einstellungen, um Fotos anzuzeigen.',
'memories.saveError': '{provider_name}-Einstellungen konnten nicht gespeichert werden',
'memories.saveRouteNotConfigured': 'Speicherroute ist für diesen Anbieter nicht konfiguriert',
'memories.testRouteNotConfigured': 'Testroute ist für diesen Anbieter nicht konfiguriert',
'memories.fillRequiredFields': 'Bitte füllen Sie alle Pflichtfelder aus',
'memories.addPhotos': 'Fotos hinzufügen', 'memories.addPhotos': 'Fotos hinzufügen',
'memories.linkAlbum': 'Album verknüpfen', 'memories.linkAlbum': 'Album verknüpfen',
'memories.selectAlbum': 'Immich-Album auswählen', 'memories.selectAlbum': 'Immich-Album auswählen',
'memories.selectAlbumMultiple': 'Album auswählen',
'memories.noAlbums': 'Keine Alben gefunden', 'memories.noAlbums': 'Keine Alben gefunden',
'memories.syncAlbum': 'Album synchronisieren', 'memories.syncAlbum': 'Album synchronisieren',
'memories.unlinkAlbum': 'Album trennen', 'memories.unlinkAlbum': 'Album trennen',
'memories.photos': 'Fotos', 'memories.photos': 'Fotos',
'memories.selectPhotos': 'Fotos aus Immich auswählen', 'memories.selectPhotos': 'Fotos aus Immich auswählen',
'memories.selectPhotosMultiple': 'Fotos auswählen',
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.', 'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
'memories.selected': 'ausgewählt', 'memories.selected': 'ausgewählt',
'memories.addSelected': '{count} Fotos hinzufügen', 'memories.addSelected': '{count} Fotos hinzufügen',
@@ -1574,6 +1684,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Als ungelesen markieren', 'notifications.markUnread': 'Als ungelesen markieren',
'notifications.delete': 'Löschen', 'notifications.delete': 'Löschen',
'notifications.system': 'System', 'notifications.system': 'System',
'notifications.synologySessionCleared.title': 'Synology Photos getrennt',
'notifications.synologySessionCleared.text': 'Dein Server oder Konto hat sich geändert — gehe zu Einstellungen, um deine Verbindung erneut zu testen.',
'memories.error.loadAlbums': 'Alben konnten nicht geladen werden', 'memories.error.loadAlbums': 'Alben konnten nicht geladen werden',
'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden', 'memories.error.linkAlbum': 'Album konnte nicht verknüpft werden',
'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden', 'memories.error.unlinkAlbum': 'Album konnte nicht getrennt werden',
@@ -1600,7 +1712,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Todo // Todo
'todo.subtab.packing': 'Packliste', 'todo.subtab.packing': 'Packliste',
'todo.subtab.todo': 'To-Do', 'todo.subtab.todo': 'Aufgaben',
'todo.completed': 'erledigt', 'todo.completed': 'erledigt',
'todo.filter.all': 'Alle', 'todo.filter.all': 'Alle',
'todo.filter.open': 'Offen', 'todo.filter.open': 'Offen',
@@ -1635,7 +1747,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Notification system (added from feat/notification-system) // Notification system (added from feat/notification-system)
'settings.notifyVersionAvailable': 'Neue Version verfügbar', 'settings.notifyVersionAvailable': 'Neue Version verfügbar',
'settings.notificationPreferences.noChannels': 'Keine Benachrichtigungskanäle konfiguriert. Bitte einen Administrator, E-Mail- oder Webhook-Benachrichtigungen einzurichten.', 'settings.notificationPreferences.noChannels': 'Keine Benachrichtigungskanäle konfiguriert. Bitte einen Administrator, E-Mail- oder Webhook-Benachrichtigungen einzurichten.',
'settings.webhookUrl.label': 'Webhook URL', 'settings.webhookUrl.label': 'Webhook-URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...', 'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': 'Gib deine Discord-, Slack- oder benutzerdefinierte Webhook-URL ein, um Benachrichtigungen zu erhalten.', 'settings.webhookUrl.hint': 'Gib deine Discord-, Slack- oder benutzerdefinierte Webhook-URL ein, um Benachrichtigungen zu erhalten.',
'settings.webhookUrl.save': 'Speichern', 'settings.webhookUrl.save': 'Speichern',
@@ -1696,6 +1808,302 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Du hast eine neue Benachrichtigung', 'notif.generic.text': 'Du hast eine neue Benachrichtigung',
'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis', 'notif.dev.unknown_event.title': '[DEV] Unbekanntes Ereignis',
'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert', 'notif.dev.unknown_event.text': 'Ereignistyp "{event}" ist nicht in EVENT_NOTIFICATION_CONFIG registriert',
// Journey Addon
'journey.title': 'Journey',
'journey.subtitle': 'Dokumentiere deine Reisen unterwegs',
'journey.new': 'Neue Journey',
'journey.create': 'Erstellen',
'journey.titlePlaceholder': 'Wohin geht die Reise?',
'journey.empty': 'Noch keine Journeys',
'journey.emptyHint': 'Starte die Dokumentation deiner naechsten Reise',
'journey.deleted': 'Journey geloescht',
'journey.createError': 'Journey konnte nicht erstellt werden',
'journey.deleteError': 'Journey konnte nicht geloescht werden',
'journey.deleteConfirmTitle': 'Loeschen',
'journey.deleteConfirmMessage': '"{title}" loeschen? Das kann nicht rueckgaengig gemacht werden.',
'journey.deleteConfirmGeneric': 'Bist du sicher, dass du das loeschen moechtest?',
'journey.notFound': 'Journey nicht gefunden',
'journey.photos': 'Fotos',
'journey.timelineEmpty': 'Noch keine Stationen',
'journey.timelineEmptyHint': 'Fuege einen Check-in hinzu oder schreibe einen Tagebucheintrag',
'journey.status.draft': 'Entwurf',
'journey.status.active': 'Aktiv',
'journey.status.completed': 'Abgeschlossen',
'journey.status.upcoming': 'Anstehend',
'journey.checkin.add': 'Einchecken',
'journey.checkin.namePlaceholder': 'Ortsname',
'journey.checkin.notesPlaceholder': 'Notizen (optional)',
'journey.checkin.save': 'Speichern',
'journey.checkin.error': 'Check-in konnte nicht gespeichert werden',
'journey.entry.add': 'Tagebuch',
'journey.entry.edit': 'Eintrag bearbeiten',
'journey.entry.titlePlaceholder': 'Titel (optional)',
'journey.entry.bodyPlaceholder': 'Was ist heute passiert?',
'journey.entry.save': 'Speichern',
'journey.entry.error': 'Eintrag konnte nicht gespeichert werden',
'journey.photo.add': 'Foto',
'journey.photo.uploadError': 'Upload fehlgeschlagen',
'journey.share.share': 'Teilen',
'journey.share.public': 'Oeffentlich',
'journey.share.linkCopied': 'Oeffentlicher Link kopiert',
'journey.share.disabled': 'Oeffentliches Teilen deaktiviert',
'journey.editor.titlePlaceholder': 'Gib diesem Moment einen Namen...',
'journey.editor.bodyPlaceholder': 'Erzaehl die Geschichte dieses Tages...',
'journey.editor.placePlaceholder': 'Ort (optional)',
'journey.editor.tagsPlaceholder': 'Tags: Geheimtipp, bestes Essen, nochmal hin...',
'journey.visibility.private': 'Privat',
'journey.visibility.shared': 'Geteilt',
'journey.visibility.public': 'Oeffentlich',
'journey.emptyState.title': 'Deine Geschichte beginnt hier',
'journey.emptyState.subtitle': 'Checke an einem Ort ein oder schreibe deinen ersten Tagebucheintrag',
'admin.addons.catalog.journey.name': 'Journey',
'admin.addons.catalog.journey.description': 'Reise-Tracking & Tagebuch mit Check-ins, Fotos und Tagesberichten',
// Journey & Mobile translations
'journey.frontpage.subtitle': 'Verwandle deine Reisen in Geschichten, die du nie vergisst',
'journey.frontpage.createJourney': 'Journey erstellen',
'journey.frontpage.activeJourney': 'Aktive Journey',
'journey.frontpage.allJourneys': 'Alle Journeys',
'journey.frontpage.journeys': 'Journeys',
'journey.frontpage.createNew': 'Neue Journey erstellen',
'journey.frontpage.createNewSub': 'Trips auswählen, Geschichten schreiben, Abenteuer teilen',
'journey.frontpage.live': 'Live',
'journey.frontpage.synced': 'Synchronisiert',
'journey.frontpage.continueWriting': 'Weiterschreiben',
'journey.frontpage.updated': 'Aktualisiert {time}',
'journey.frontpage.suggestionLabel': 'Trip gerade beendet',
'journey.frontpage.suggestionText': 'Verwandle <strong>{title}</strong> in eine Journey',
'journey.frontpage.dismiss': 'Schließen',
'journey.frontpage.journeyName': 'Journey-Name',
'journey.frontpage.namePlaceholder': 'z.B. Südostasien 2026',
'journey.frontpage.selectTrips': 'Trips auswählen',
'journey.frontpage.tripsSelected': 'Trips ausgewählt',
'journey.frontpage.trips': 'Trips',
'journey.frontpage.placesImported': 'Orte werden importiert',
'journey.frontpage.places': 'Orte',
'journey.detail.backToJourney': 'Zurück zur Journey',
'journey.detail.syncedWithTrips': 'Mit Trips synchronisiert',
'journey.detail.addEntry': 'Eintrag hinzufügen',
'journey.detail.newEntry': 'Neuer Eintrag',
'journey.detail.editEntry': 'Eintrag bearbeiten',
'journey.detail.noEntries': 'Noch keine Einträge',
'journey.detail.noEntriesHint': 'Füge einen Trip hinzu, um mit Skelett-Einträgen zu starten',
'journey.detail.noPhotos': 'Noch keine Fotos',
'journey.detail.noPhotosHint': 'Lade Fotos hoch oder durchsuche deine Immich/Synology-Bibliothek',
'journey.detail.journeyStats': 'Journey-Statistiken',
'journey.detail.syncedTrips': 'Verknüpfte Trips',
'journey.detail.noTripsLinked': 'Noch keine Trips verknüpft',
'journey.detail.contributors': 'Mitwirkende',
'journey.detail.readMore': 'Mehr lesen',
'journey.detail.prosCons': 'Pro & Contra',
'journey.stats.days': 'Tage',
'journey.stats.cities': 'Städte',
'journey.stats.entries': 'Einträge',
'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Orte',
'journey.verdict.lovedIt': 'Toll',
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
'journey.synced.places': 'Orte',
'journey.synced.synced': 'synchronisiert',
'journey.editor.uploadPhotos': 'Fotos hochladen',
'journey.editor.fromGallery': 'Aus Galerie',
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
'journey.editor.prosCons': 'Pro & Contra',
'journey.editor.pros': 'Pro',
'journey.editor.cons': 'Contra',
'journey.editor.proPlaceholder': 'Etwas Positives...',
'journey.editor.conPlaceholder': 'Nicht so toll...',
'journey.editor.addAnother': 'Hinzufügen',
'journey.editor.date': 'Datum',
'journey.editor.location': 'Ort',
'journey.editor.searchLocation': 'Ort suchen...',
'journey.editor.mood': 'Stimmung',
'journey.editor.weather': 'Wetter',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Als 1. setzen',
'journey.mood.amazing': 'Großartig',
'journey.mood.good': 'Gut',
'journey.mood.neutral': 'Neutral',
'journey.mood.rough': 'Schwierig',
'journey.weather.sunny': 'Sonnig',
'journey.weather.partly': 'Teilweise bewölkt',
'journey.weather.cloudy': 'Bewölkt',
'journey.weather.rainy': 'Regnerisch',
'journey.weather.stormy': 'Stürmisch',
'journey.weather.cold': 'Schnee',
'journey.trips.linkTrip': 'Trip verknüpfen',
'journey.trips.searchTrip': 'Trip suchen',
'journey.trips.searchPlaceholder': 'Tripname oder Reiseziel...',
'journey.trips.noTripsAvailable': 'Keine Trips verfügbar',
'journey.trips.link': 'Verknüpfen',
'journey.trips.tripLinked': 'Trip verknüpft',
'journey.trips.linkFailed': 'Verknüpfung fehlgeschlagen',
'journey.trips.addTrip': 'Trip hinzufügen',
'journey.trips.unlinkTrip': 'Trip trennen',
'journey.trips.unlinkMessage': '"{title}" trennen? Alle synchronisierten Einträge und Fotos dieses Trips werden unwiderruflich gelöscht.',
'journey.trips.unlink': 'Trennen',
'journey.trips.tripUnlinked': 'Trip getrennt',
'journey.trips.unlinkFailed': 'Trennung fehlgeschlagen',
'journey.trips.noTripsLinkedSettings': 'Keine Trips verknüpft',
'journey.contributors.invite': 'Mitwirkenden einladen',
'journey.contributors.searchUser': 'Benutzer suchen',
'journey.contributors.searchPlaceholder': 'Benutzername oder E-Mail...',
'journey.contributors.noUsers': 'Keine Benutzer gefunden',
'journey.contributors.role': 'Rolle',
'journey.contributors.added': 'Mitwirkender hinzugefügt',
'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen',
'journey.share.publicShare': 'Öffentlicher Link',
'journey.share.createLink': 'Link erstellen',
'journey.share.linkCreated': 'Link erstellt',
'journey.share.createFailed': 'Link konnte nicht erstellt werden',
'journey.share.copy': 'Kopieren',
'journey.share.copied': 'Kopiert!',
'journey.share.timeline': 'Zeitstrahl',
'journey.share.gallery': 'Galerie',
'journey.share.map': 'Karte',
'journey.share.removeLink': 'Link entfernen',
'journey.share.linkDeleted': 'Link entfernt',
'journey.share.deleteFailed': 'Entfernen fehlgeschlagen',
'journey.share.updateFailed': 'Aktualisierung fehlgeschlagen',
'journey.settings.title': 'Journey-Einstellungen',
'journey.settings.coverImage': 'Titelbild',
'journey.settings.changeCover': 'Titelbild ändern',
'journey.settings.addCover': 'Titelbild hinzufügen',
'journey.settings.name': 'Name',
'journey.settings.subtitle': 'Untertitel',
'journey.settings.subtitlePlaceholder': 'z.B. Thailand, Vietnam & Kambodscha',
'journey.settings.delete': 'Löschen',
'journey.settings.deleteJourney': 'Journey löschen',
'journey.settings.deleteMessage': '"{title}" löschen? Alle Einträge und Fotos gehen verloren.',
'journey.settings.saved': 'Einstellungen gespeichert',
'journey.settings.saveFailed': 'Speichern fehlgeschlagen',
'journey.settings.coverUpdated': 'Titelbild aktualisiert',
'journey.settings.coverFailed': 'Upload fehlgeschlagen',
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
'journey.entries.deleteTitle': 'Eintrag löschen',
'journey.photosUploaded': '{count} Fotos hochgeladen',
'journey.photosAdded': '{count} Fotos hinzugefügt',
'journey.public.notFound': 'Nicht gefunden',
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
'journey.public.readOnly': 'Nur lesen · Öffentliche Journey',
'journey.public.tagline': 'Travel Resource & Exploration Kit',
'journey.public.sharedVia': 'Geteilt über',
'journey.public.madeWith': 'Erstellt mit',
'journey.pdf.journeyBook': 'Reisebuch',
'journey.pdf.madeWith': 'Erstellt mit TREK',
'journey.pdf.day': 'Tag',
'journey.pdf.theEnd': 'Ende',
'journey.pdf.saveAsPdf': 'Als PDF speichern',
'journey.pdf.pages': 'Seiten',
'dashboard.greeting.morning': 'Guten Morgen,',
'dashboard.greeting.afternoon': 'Guten Tag,',
'dashboard.greeting.evening': 'Guten Abend,',
'dashboard.mobile.liveNow': 'Jetzt live',
'dashboard.mobile.tripProgress': 'Reisefortschritt',
'dashboard.mobile.daysLeft': '{count} Tage übrig',
'dashboard.mobile.places': 'Orte',
'dashboard.mobile.buddies': 'Freunde',
'dashboard.mobile.newTrip': 'Neuer Trip',
'dashboard.mobile.currency': 'Währung',
'dashboard.mobile.timezone': 'Zeitzone',
'dashboard.mobile.upcomingTrips': 'Anstehende Trips',
'dashboard.mobile.yourTrips': 'Deine Trips',
'dashboard.mobile.trips': 'Trips',
'dashboard.mobile.starts': 'Beginn',
'dashboard.mobile.duration': 'Dauer',
'dashboard.mobile.day': 'Tag',
'dashboard.mobile.days': 'Tage',
'dashboard.mobile.ongoing': 'Laufend',
'dashboard.mobile.startsToday': 'Beginnt heute',
'dashboard.mobile.tomorrow': 'Morgen',
'dashboard.mobile.inDays': 'In {count} Tagen',
'dashboard.mobile.inMonths': 'In {count} Monaten',
'dashboard.mobile.completed': 'Abgeschlossen',
'dashboard.mobile.currencyConverter': 'Währungsrechner',
'nav.profile': 'Profil',
'nav.bottomSettings': 'Einstellungen',
'nav.bottomAdmin': 'Admin-Einstellungen',
'nav.bottomLogout': 'Abmelden',
'nav.bottomAdminBadge': 'Admin',
'dayplan.mobile.addPlace': 'Ort hinzufügen',
'dayplan.mobile.searchPlaces': 'Orte suchen...',
'dayplan.mobile.allAssigned': 'Alle Orte zugeordnet',
'dayplan.mobile.noMatch': 'Kein Treffer',
'dayplan.mobile.createNew': 'Neuen Ort erstellen',
'memories.notConnectedMultipleHint': 'Connect any of these photo providers: {provider_names} in Settings to be able add photos to this trip.',
'memories.providerUrl': 'Server URL',
'memories.providerApiKey': 'API Key',
'memories.providerUsername': 'Username',
'memories.providerPassword': 'Password',
'memories.saveError': 'Could not save {provider_name} settings',
'memories.selectAlbumMultiple': 'Select Album',
'memories.selectPhotosMultiple': 'Select Photos',
// OAuth scope groups
'oauth.scope.group.trips': 'Reisen',
'oauth.scope.group.places': 'Orte',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Packliste',
'oauth.scope.group.todos': 'Aufgaben',
'oauth.scope.group.budget': 'Budget',
'oauth.scope.group.reservations': 'Buchungen',
'oauth.scope.group.collab': 'Zusammenarbeit',
'oauth.scope.group.notifications': 'Benachrichtigungen',
'oauth.scope.group.vacay': 'Urlaub',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Wetter',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Reisen und Reisepläne anzeigen',
'oauth.scope.trips:read.description': 'Reisen, Tage, Tagesnotizen und Mitglieder lesen',
'oauth.scope.trips:write.label': 'Reisen und Reisepläne bearbeiten',
'oauth.scope.trips:write.description': 'Reisen, Tage und Notizen erstellen, aktualisieren und Mitglieder verwalten',
'oauth.scope.trips:delete.label': 'Reisen löschen',
'oauth.scope.trips:delete.description': 'Reisen dauerhaft löschen — diese Aktion ist unwiderruflich',
'oauth.scope.trips:share.label': 'Freigabelinks verwalten',
'oauth.scope.trips:share.description': 'Öffentliche Freigabelinks erstellen, aktualisieren und widerrufen',
'oauth.scope.places:read.label': 'Orte und Kartendaten anzeigen',
'oauth.scope.places:read.description': 'Orte, Tageszuweisungen, Tags und Kategorien lesen',
'oauth.scope.places:write.label': 'Orte verwalten',
'oauth.scope.places:write.description': 'Orte, Zuweisungen und Tags erstellen, aktualisieren und löschen',
'oauth.scope.atlas:read.label': 'Atlas anzeigen',
'oauth.scope.atlas:read.description': 'Besuchte Länder, Regionen und Wunschliste lesen',
'oauth.scope.atlas:write.label': 'Atlas verwalten',
'oauth.scope.atlas:write.description': 'Länder und Regionen als besucht markieren, Wunschliste verwalten',
'oauth.scope.packing:read.label': 'Packlisten anzeigen',
'oauth.scope.packing:read.description': 'Packgegenstände, Taschen und Kategoriezuweisungen lesen',
'oauth.scope.packing:write.label': 'Packlisten verwalten',
'oauth.scope.packing:write.description': 'Packgegenstände und Taschen hinzufügen, aktualisieren, löschen, abhaken und sortieren',
'oauth.scope.todos:read.label': 'Aufgabenlisten anzeigen',
'oauth.scope.todos:read.description': 'Reiseaufgaben und Kategoriezuweisungen lesen',
'oauth.scope.todos:write.label': 'Aufgabenlisten verwalten',
'oauth.scope.todos:write.description': 'Aufgaben erstellen, aktualisieren, abhaken, löschen und sortieren',
'oauth.scope.budget:read.label': 'Budget anzeigen',
'oauth.scope.budget:read.description': 'Budgeteinträge und Ausgabenaufschlüsselung lesen',
'oauth.scope.budget:write.label': 'Budget verwalten',
'oauth.scope.budget:write.description': 'Budgeteinträge erstellen, aktualisieren und löschen',
'oauth.scope.reservations:read.label': 'Buchungen anzeigen',
'oauth.scope.reservations:read.description': 'Buchungen und Unterkunftsdetails lesen',
'oauth.scope.reservations:write.label': 'Buchungen verwalten',
'oauth.scope.reservations:write.description': 'Buchungen erstellen, aktualisieren, löschen und sortieren',
'oauth.scope.collab:read.label': 'Zusammenarbeit anzeigen',
'oauth.scope.collab:read.description': 'Kollaborationsnotizen, Umfragen und Nachrichten lesen',
'oauth.scope.collab:write.label': 'Zusammenarbeit verwalten',
'oauth.scope.collab:write.description': 'Kollaborationsnotizen, Umfragen und Nachrichten erstellen, aktualisieren und löschen',
'oauth.scope.notifications:read.label': 'Benachrichtigungen anzeigen',
'oauth.scope.notifications:read.description': 'In-App-Benachrichtigungen und ungelesene Zählungen lesen',
'oauth.scope.notifications:write.label': 'Benachrichtigungen verwalten',
'oauth.scope.notifications:write.description': 'Benachrichtigungen als gelesen markieren und darauf reagieren',
'oauth.scope.vacay:read.label': 'Urlaubspläne anzeigen',
'oauth.scope.vacay:read.description': 'Urlaubsplanungsdaten, Einträge und Statistiken lesen',
'oauth.scope.vacay:write.label': 'Urlaubspläne verwalten',
'oauth.scope.vacay:write.description': 'Urlaubseinträge, Feiertage und Teampläne erstellen und verwalten',
'oauth.scope.geo:read.label': 'Karten & Geocodierung',
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
'oauth.scope.weather:read.label': 'Wettervorhersagen',
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
} }
export default de export default de
+434 -4
View File
@@ -8,6 +8,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.loading': 'Loading...', 'common.loading': 'Loading...',
'common.import': 'Import', 'common.import': 'Import',
'common.error': 'Error', 'common.error': 'Error',
'common.unknownError': 'Unknown error',
'common.tooManyAttempts': 'Too many attempts. Please try again later.',
'common.back': 'Back', 'common.back': 'Back',
'common.all': 'All', 'common.all': 'All',
'common.close': 'Close', 'common.close': 'Close',
@@ -26,7 +28,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
'common.saving': 'Saving...', 'common.saving': 'Saving...',
'common.justNow': 'just now',
'common.hoursAgo': '{count}h ago',
'common.daysAgo': '{count}d ago',
'common.saved': 'Saved', 'common.saved': 'Saved',
'trips.memberRemoved': '{username} removed',
'trips.memberRemoveError': 'Failed to remove',
'trips.memberAdded': '{username} added',
'trips.memberAddError': 'Failed to add',
'trips.reminder': 'Reminder', 'trips.reminder': 'Reminder',
'trips.reminderNone': 'None', 'trips.reminderNone': 'None',
'trips.reminderDay': 'day', 'trips.reminderDay': 'day',
@@ -39,6 +48,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.uploading': 'Uploading…', 'common.uploading': 'Uploading…',
'common.backToPlanning': 'Back to Planning', 'common.backToPlanning': 'Back to Planning',
'common.reset': 'Reset', 'common.reset': 'Reset',
'common.expand': 'Expand',
'common.collapse': 'Collapse',
// Navbar // Navbar
'nav.trip': 'Trip', 'nav.trip': 'Trip',
@@ -249,6 +260,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'MCP Endpoint', 'settings.mcp.endpoint': 'MCP Endpoint',
'settings.mcp.clientConfig': 'Client Configuration', 'settings.mcp.clientConfig': 'Client Configuration',
'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).', 'settings.mcp.clientConfigHint': 'Replace <your_token> with an API token from the list below. The path to npx may need to be adjusted for your system (e.g. C:\\PROGRA~1\\nodejs\\npx.cmd on Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Copy', 'settings.mcp.copy': 'Copy',
'settings.mcp.copied': 'Copied!', 'settings.mcp.copied': 'Copied!',
'settings.mcp.apiTokens': 'API Tokens', 'settings.mcp.apiTokens': 'API Tokens',
@@ -270,6 +282,48 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'Failed to create token', 'settings.mcp.toast.createError': 'Failed to create token',
'settings.mcp.toast.deleted': 'Token deleted', 'settings.mcp.toast.deleted': 'Token deleted',
'settings.mcp.toast.deleteError': 'Failed to delete token', 'settings.mcp.toast.deleteError': 'Failed to delete token',
'settings.mcp.apiTokensDeprecated': 'API Tokens are deprecated and will be removed in a future release. Please use OAuth 2.1 Clients instead.',
'settings.oauth.clients': 'OAuth 2.1 Clients',
'settings.oauth.clientsHint': 'Register OAuth 2.1 clients to let third-party MCP applications (Claude Web, Cursor, etc.) connect without static tokens.',
'settings.oauth.createClient': 'New Client',
'settings.oauth.noClients': 'No OAuth clients registered.',
'settings.oauth.clientId': 'Client ID',
'settings.oauth.clientSecret': 'Client Secret',
'settings.oauth.deleteClient': 'Delete Client',
'settings.oauth.deleteClientMessage': 'This client and all active sessions will be permanently removed. Any application using it will lose access immediately.',
'settings.oauth.rotateSecret': 'Rotate Secret',
'settings.oauth.rotateSecretMessage': 'A new client secret will be generated and all existing sessions will be invalidated immediately. Update your application before closing this dialog.',
'settings.oauth.rotateSecretConfirm': 'Rotate',
'settings.oauth.rotateSecretConfirming': 'Rotating…',
'settings.oauth.rotateSecretDoneTitle': 'New Secret Generated',
'settings.oauth.rotateSecretDoneWarning': 'This secret is shown only once. Copy it now and update your application — all previous sessions have been invalidated.',
'settings.oauth.activeSessions': 'Active OAuth Sessions',
'settings.oauth.sessionScopes': 'Scopes',
'settings.oauth.sessionExpires': 'Expires',
'settings.oauth.revoke': 'Revoke',
'settings.oauth.revokeSession': 'Revoke Session',
'settings.oauth.revokeSessionMessage': 'This will immediately revoke access for this OAuth session.',
'settings.oauth.modal.createTitle': 'Register OAuth Client',
'settings.oauth.modal.presets': 'Quick presets',
'settings.oauth.modal.clientName': 'Application Name',
'settings.oauth.modal.clientNamePlaceholder': 'e.g. Claude Web, My MCP App',
'settings.oauth.modal.redirectUris': 'Redirect URIs',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'One URI per line. HTTPS required (localhost exempt). Exact match enforced.',
'settings.oauth.modal.scopes': 'Allowed Scopes',
'settings.oauth.modal.scopesHint': 'list_trips and get_trip_summary are always available — no scope required. They let the AI discover trip IDs needed to use any other tool.',
'settings.oauth.modal.selectAll': 'Select all',
'settings.oauth.modal.deselectAll': 'Deselect all',
'settings.oauth.modal.creating': 'Registering…',
'settings.oauth.modal.create': 'Register Client',
'settings.oauth.modal.createdTitle': 'Client Registered',
'settings.oauth.modal.createdWarning': 'The client secret is shown only once. Copy it now — it cannot be recovered.',
'settings.oauth.toast.createError': 'Failed to register OAuth client',
'settings.oauth.toast.deleted': 'OAuth client deleted',
'settings.oauth.toast.deleteError': 'Failed to delete OAuth client',
'settings.oauth.toast.revoked': 'Session revoked',
'settings.oauth.toast.revokeError': 'Failed to revoke session',
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
'settings.account': 'Account', 'settings.account': 'Account',
'settings.about': 'About', 'settings.about': 'About',
'settings.about.reportBug': 'Report a Bug', 'settings.about.reportBug': 'Report a Bug',
@@ -392,6 +446,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.', 'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.',
'login.mfaBack': '← Back to sign in', 'login.mfaBack': '← Back to sign in',
'login.mfaVerify': 'Verify', 'login.mfaVerify': 'Verify',
'login.invalidInviteLink': 'Invalid or expired invite link',
'login.oidcFailed': 'OIDC login failed',
'login.usernameRequired': 'Username is required',
'login.passwordMinLength': 'Password must be at least 8 characters',
// Register // Register
'register.passwordMismatch': 'Passwords do not match', 'register.passwordMismatch': 'Passwords do not match',
@@ -472,6 +530,17 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Settings', 'admin.tabs.settings': 'Settings',
'admin.allowRegistration': 'Allow Registration', 'admin.allowRegistration': 'Allow Registration',
'admin.allowRegistrationHint': 'New users can register themselves', 'admin.allowRegistrationHint': 'New users can register themselves',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Require two-factor authentication (2FA)', 'admin.requireMfa': 'Require two-factor authentication (2FA)',
'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.', 'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.',
'admin.apiKeys': 'API Keys', 'admin.apiKeys': 'API Keys',
@@ -570,9 +639,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.', 'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
// GitHub // GitHub
'admin.tabs.mcpTokens': 'MCP Tokens', 'admin.tabs.mcpTokens': 'MCP Access',
'admin.mcpTokens.title': 'MCP Tokens', 'admin.mcpTokens.title': 'MCP Access',
'admin.mcpTokens.subtitle': 'Manage API tokens across all users', 'admin.mcpTokens.subtitle': 'Manage OAuth sessions and API tokens across all users',
'admin.mcpTokens.sectionTitle': 'API Tokens',
'admin.mcpTokens.owner': 'Owner', 'admin.mcpTokens.owner': 'Owner',
'admin.mcpTokens.tokenName': 'Token Name', 'admin.mcpTokens.tokenName': 'Token Name',
'admin.mcpTokens.created': 'Created', 'admin.mcpTokens.created': 'Created',
@@ -584,6 +654,17 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.mcpTokens.deleteSuccess': 'Token deleted', 'admin.mcpTokens.deleteSuccess': 'Token deleted',
'admin.mcpTokens.deleteError': 'Failed to delete token', 'admin.mcpTokens.deleteError': 'Failed to delete token',
'admin.mcpTokens.loadError': 'Failed to load tokens', 'admin.mcpTokens.loadError': 'Failed to load tokens',
'admin.oauthSessions.sectionTitle': 'OAuth Sessions',
'admin.oauthSessions.clientName': 'Client',
'admin.oauthSessions.owner': 'Owner',
'admin.oauthSessions.scopes': 'Scopes',
'admin.oauthSessions.created': 'Created',
'admin.oauthSessions.empty': 'No active OAuth sessions',
'admin.oauthSessions.revokeTitle': 'Revoke Session',
'admin.oauthSessions.revokeMessage': 'This will revoke the OAuth session immediately. The client will lose MCP access.',
'admin.oauthSessions.revokeSuccess': 'Session revoked',
'admin.oauthSessions.revokeError': 'Failed to revoke session',
'admin.oauthSessions.loadError': 'Failed to load OAuth sessions',
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).', 'admin.audit.subtitle': 'Security-sensitive and administration events (backups, users, MFA, settings).',
@@ -680,6 +761,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'vacay.companyHolidays': 'Company Holidays', 'vacay.companyHolidays': 'Company Holidays',
'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days', 'vacay.companyHolidaysHint': 'Allow marking company-wide holiday days',
'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.', 'vacay.companyHolidaysNoDeduct': 'Company holidays do not count towards vacation days.',
'vacay.weekStart': 'Week starts on',
'vacay.weekStartHint': 'Choose whether the calendar week starts on Monday or Sunday',
'vacay.carryOver': 'Carry Over', 'vacay.carryOver': 'Carry Over',
'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year', 'vacay.carryOverHint': 'Automatically carry remaining vacation days into the next year',
'vacay.sharing': 'Sharing', 'vacay.sharing': 'Sharing',
@@ -889,6 +972,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'inspector.showHours': 'Show opening hours', 'inspector.showHours': 'Show opening hours',
'inspector.files': 'Files', 'inspector.files': 'Files',
'inspector.filesCount': '{count} files', 'inspector.filesCount': '{count} files',
'inspector.remove': 'Remove',
'inspector.removeFromDay': 'Remove from Day', 'inspector.removeFromDay': 'Remove from Day',
'inspector.addToDay': 'Add to Day', 'inspector.addToDay': 'Add to Day',
'inspector.confirmedRes': 'Confirmed Reservation', 'inspector.confirmedRes': 'Confirmed Reservation',
@@ -1043,6 +1127,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Files // Files
'files.title': 'Files', 'files.title': 'Files',
'files.pageTitle': 'Files & Documents',
'files.subtitle': '{count} files for {trip}',
'files.downloadPdf': 'Download PDF',
'files.count': '{count} files', 'files.count': '{count} files',
'files.countSingular': '1 file', 'files.countSingular': '1 file',
'files.uploaded': '{count} uploaded', 'files.uploaded': '{count} uploaded',
@@ -1133,7 +1220,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'packing.saveAsTemplate': 'Save as template', 'packing.saveAsTemplate': 'Save as template',
'packing.templateName': 'Template name', 'packing.templateName': 'Template name',
'packing.templateSaved': 'Packing list saved as template', 'packing.templateSaved': 'Packing list saved as template',
'packing.assignUser': 'Assign user',
'packing.bags': 'Bags', 'packing.bags': 'Bags',
'packing.noBag': 'Unassigned', 'packing.noBag': 'Unassigned',
'packing.totalWeight': 'Total weight', 'packing.totalWeight': 'Total weight',
@@ -1289,6 +1375,13 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'backup.keep.forever': 'Keep forever', 'backup.keep.forever': 'Keep forever',
// Photos // Photos
'photos.title': 'Photos',
'photos.subtitle': '{count} photos for {trip}',
'photos.dropHere': 'Drop photos here...',
'photos.dropHereActive': 'Drop photos here',
'photos.captionForAll': 'Caption (for all)',
'photos.captionPlaceholder': 'Optional caption...',
'photos.addCaption': 'Add caption...',
'photos.allDays': 'All Days', 'photos.allDays': 'All Days',
'photos.noPhotos': 'No photos yet', 'photos.noPhotos': 'No photos yet',
'photos.uploadHint': 'Upload your travel photos', 'photos.uploadHint': 'Upload your travel photos',
@@ -1296,6 +1389,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'photos.linkPlace': 'Link Place', 'photos.linkPlace': 'Link Place',
'photos.noPlace': 'No Place', 'photos.noPlace': 'No Place',
'photos.uploadN': '{n} photo(s) upload', 'photos.uploadN': '{n} photo(s) upload',
'photos.linkDay': 'Link Day',
'photos.noDay': 'No Day',
'photos.dayLabel': 'Day {number}',
'photos.photoSelected': 'Photo selected',
'photos.photosSelected': 'Photos selected',
'photos.fileTypeHint': 'JPG, PNG, WebP · max. 10 MB · up to 30 photos',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Restore Backup?', 'backup.restoreConfirmTitle': 'Restore Backup?',
@@ -1322,6 +1421,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'planner.routeCalculated': 'Route calculated', 'planner.routeCalculated': 'Route calculated',
'planner.routeCalcFailed': 'Route could not be calculated', 'planner.routeCalcFailed': 'Route could not be calculated',
'planner.routeError': 'Error calculating route', 'planner.routeError': 'Error calculating route',
'planner.icsExportFailed': 'ICS export failed',
'planner.routeOptimized': 'Route optimized', 'planner.routeOptimized': 'Route optimized',
'planner.reservationUpdated': 'Reservation updated', 'planner.reservationUpdated': 'Reservation updated',
'planner.reservationAdded': 'Reservation added', 'planner.reservationAdded': 'Reservation added',
@@ -1424,6 +1524,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.providerApiKey': 'API Key', 'memories.providerApiKey': 'API Key',
'memories.providerUsername': 'Username', 'memories.providerUsername': 'Username',
'memories.providerPassword': 'Password', 'memories.providerPassword': 'Password',
'memories.providerOTP': 'MFA code (if enabled)',
'memories.skipSSLVerification': 'Skip SSL certificate verification',
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo',
'memories.testConnection': 'Test connection', 'memories.testConnection': 'Test connection',
'memories.testFirst': 'Test connection first', 'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected', 'memories.connected': 'Connected',
@@ -1431,6 +1534,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.connectionSuccess': 'Connected to {provider_name}', 'memories.connectionSuccess': 'Connected to {provider_name}',
'memories.connectionError': 'Could not connect to {provider_name}', 'memories.connectionError': 'Could not connect to {provider_name}',
'memories.saved': '{provider_name} settings saved', 'memories.saved': '{provider_name} settings saved',
'memories.providerDisconnectedBanner': 'Your {provider_name} connection is lost. Reconnect in Settings to view photos.',
'memories.saveError': 'Could not save {provider_name} settings', 'memories.saveError': 'Could not save {provider_name} settings',
//------------------------ //------------------------
'memories.addPhotos': 'Add photos', 'memories.addPhotos': 'Add photos',
@@ -1465,6 +1569,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.error.addPhotos': 'Failed to add photos', 'memories.error.addPhotos': 'Failed to add photos',
'memories.error.removePhoto': 'Failed to remove photo', 'memories.error.removePhoto': 'Failed to remove photo',
'memories.error.toggleSharing': 'Failed to update sharing', 'memories.error.toggleSharing': 'Failed to update sharing',
'memories.saveRouteNotConfigured': 'Save route is not configured for this provider',
'memories.testRouteNotConfigured': 'Test route is not configured for this provider',
'memories.fillRequiredFields': 'Please fill all required fields',
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Chat', 'collab.tabs.chat': 'Chat',
@@ -1614,6 +1721,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Mark as unread', 'notifications.markUnread': 'Mark as unread',
'notifications.delete': 'Delete', 'notifications.delete': 'Delete',
'notifications.system': 'System', 'notifications.system': 'System',
'notifications.synologySessionCleared.title': 'Synology Photos disconnected',
'notifications.synologySessionCleared.text': 'Your server or account changed — go to Settings to test your connection again.',
// Notification test keys (dev only) // Notification test keys (dev only)
'notifications.versionAvailable.title': 'Update Available', 'notifications.versionAvailable.title': 'Update Available',
@@ -1703,6 +1812,327 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'You have a new notification', 'notif.generic.text': 'You have a new notification',
'notif.dev.unknown_event.title': '[DEV] Unknown Event', 'notif.dev.unknown_event.title': '[DEV] Unknown Event',
'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'Event type "{event}" is not registered in EVENT_NOTIFICATION_CONFIG',
// Journey addon
'journey.title': 'Journey',
'journey.subtitle': 'Track your travels as they happen',
'journey.new': 'New Journey',
'journey.create': 'Create',
'journey.titlePlaceholder': 'Where are you going?',
'journey.empty': 'No journeys yet',
'journey.emptyHint': 'Start documenting your next trip',
'journey.deleted': 'Journey deleted',
'journey.createError': 'Could not create journey',
'journey.deleteError': 'Could not delete journey',
'journey.deleteConfirmTitle': 'Delete',
'journey.deleteConfirmMessage': 'Delete "{title}"? This cannot be undone.',
'journey.deleteConfirmGeneric': 'Are you sure you want to delete this?',
'journey.notFound': 'Journey not found',
'journey.photos': 'Photos',
'journey.timelineEmpty': 'No stops yet',
'journey.timelineEmptyHint': 'Add a check-in or write a journal entry to get started',
'journey.status.draft': 'Draft',
'journey.status.active': 'Active',
'journey.status.completed': 'Completed',
'journey.status.upcoming': 'Upcoming',
'journey.checkin.add': 'Check in',
'journey.checkin.namePlaceholder': 'Location name',
'journey.checkin.notesPlaceholder': 'Notes (optional)',
'journey.checkin.save': 'Save',
'journey.checkin.error': 'Could not save check-in',
'journey.entry.add': 'Journal',
'journey.entry.edit': 'Edit entry',
'journey.entry.titlePlaceholder': 'Title (optional)',
'journey.entry.bodyPlaceholder': 'What happened today?',
'journey.entry.save': 'Save',
'journey.entry.error': 'Could not save entry',
'journey.photo.add': 'Photo',
'journey.photo.uploadError': 'Upload failed',
'journey.share.share': 'Share',
'journey.share.public': 'Public',
'journey.share.linkCopied': 'Public link copied',
'journey.share.disabled': 'Public sharing disabled',
'journey.editor.titlePlaceholder': 'Give this moment a name...',
'journey.editor.bodyPlaceholder': 'Tell the story of this day...',
'journey.editor.placePlaceholder': 'Location (optional)',
'journey.editor.tagsPlaceholder': 'Tags: hidden gem, best meal, must revisit...',
'journey.visibility.private': 'Private',
'journey.visibility.shared': 'Shared',
'journey.visibility.public': 'Public',
'journey.emptyState.title': 'Your story starts here',
'journey.emptyState.subtitle': 'Check in at a place or write your first journal entry',
// Journey Frontpage
'journey.frontpage.subtitle': 'Turn your trips into stories you\'ll never forget',
'journey.frontpage.createJourney': 'Create Journey',
'journey.frontpage.activeJourney': 'Active Journey',
'journey.frontpage.allJourneys': 'All Journeys',
'journey.frontpage.journeys': 'journeys',
'journey.frontpage.createNew': 'Create a new Journey',
'journey.frontpage.createNewSub': 'Pick trips, write stories, share your adventures',
'journey.frontpage.live': 'Live',
'journey.frontpage.synced': 'Synced',
'journey.frontpage.continueWriting': 'Continue writing',
'journey.frontpage.updated': 'Updated {time}',
'journey.frontpage.suggestionLabel': 'Trip just ended',
'journey.frontpage.suggestionText': 'Turn <strong>{title}</strong> into a Journey',
'journey.frontpage.dismiss': 'Dismiss',
'journey.frontpage.journeyName': 'Journey Name',
'journey.frontpage.namePlaceholder': 'e.g. Southeast Asia 2026',
'journey.frontpage.selectTrips': 'Select Trips',
'journey.frontpage.tripsSelected': 'trips selected',
'journey.frontpage.trips': 'trips',
'journey.frontpage.placesImported': 'places will be imported',
'journey.frontpage.places': 'places',
// Journey Detail
'journey.detail.backToJourney': 'Back to Journey',
'journey.detail.syncedWithTrips': 'Synced with Trips',
'journey.detail.addEntry': 'Add Entry',
'journey.detail.newEntry': 'New Entry',
'journey.detail.editEntry': 'Edit Entry',
'journey.detail.noEntries': 'No entries yet',
'journey.detail.noEntriesHint': 'Add a trip to get started with skeleton entries',
'journey.detail.noPhotos': 'No photos yet',
'journey.detail.noPhotosHint': 'Upload photos to entries or browse your Immich/Synology library',
'journey.detail.journeyStats': 'Journey Stats',
'journey.detail.syncedTrips': 'Synced Trips',
'journey.detail.noTripsLinked': 'No trips linked yet',
'journey.detail.contributors': 'Contributors',
'journey.detail.readMore': 'Read more',
'journey.detail.prosCons': 'Pros & Cons',
// Journey Detail — Stats
'journey.stats.days': 'Days',
'journey.stats.cities': 'Cities',
'journey.stats.entries': 'Entries',
'journey.stats.photos': 'Photos',
'journey.stats.places': 'Places',
// Journey Detail — Verdict
'journey.verdict.lovedIt': 'Loved it',
'journey.verdict.couldBeBetter': 'Could be better',
// Journey Detail — Synced badge
'journey.synced.places': 'places',
'journey.synced.synced': 'synced',
// Journey Entry Editor
'journey.editor.uploadPhotos': 'Upload photos',
'journey.editor.fromGallery': 'From Gallery',
'journey.editor.allPhotosAdded': 'All photos already added',
'journey.editor.writeStory': 'Write your story...',
'journey.editor.prosCons': 'Pros & Cons',
'journey.editor.pros': 'Pros',
'journey.editor.cons': 'Cons',
'journey.editor.proPlaceholder': 'Something great...',
'journey.editor.conPlaceholder': 'Not so great...',
'journey.editor.addAnother': 'Add another',
'journey.editor.date': 'Date',
'journey.editor.location': 'Location',
'journey.editor.searchLocation': 'Search location...',
'journey.editor.mood': 'Mood',
'journey.editor.weather': 'Weather',
'journey.editor.photoFirst': '1st',
'journey.editor.makeFirst': 'Make 1st',
// Journey Entry — Moods
'journey.mood.amazing': 'Amazing',
'journey.mood.good': 'Good',
'journey.mood.neutral': 'Neutral',
'journey.mood.rough': 'Rough',
// Journey Entry — Weather
'journey.weather.sunny': 'Sunny',
'journey.weather.partly': 'Partly cloudy',
'journey.weather.cloudy': 'Cloudy',
'journey.weather.rainy': 'Rainy',
'journey.weather.stormy': 'Stormy',
'journey.weather.cold': 'Snowy',
// Journey — Trip Linking
'journey.trips.linkTrip': 'Link Trip',
'journey.trips.searchTrip': 'Search Trip',
'journey.trips.searchPlaceholder': 'Trip name or destination...',
'journey.trips.noTripsAvailable': 'No trips available',
'journey.trips.link': 'Link',
'journey.trips.tripLinked': 'Trip linked',
'journey.trips.linkFailed': 'Failed to link trip',
'journey.trips.addTrip': 'Add Trip',
'journey.trips.unlinkTrip': 'Unlink Trip',
'journey.trips.unlinkMessage': 'Unlink "{title}"? All synced entries and photos from this trip will be permanently deleted. This cannot be undone.',
'journey.trips.unlink': 'Unlink',
'journey.trips.tripUnlinked': 'Trip unlinked',
'journey.trips.unlinkFailed': 'Failed to unlink trip',
'journey.trips.noTripsLinkedSettings': 'No trips linked',
// Journey — Contributors
'journey.contributors.invite': 'Invite Contributor',
'journey.contributors.searchUser': 'Search User',
'journey.contributors.searchPlaceholder': 'Username or email...',
'journey.contributors.noUsers': 'No users found',
'journey.contributors.role': 'Role',
'journey.contributors.added': 'Contributor added',
'journey.contributors.addFailed': 'Failed to add contributor',
// Journey — Share
'journey.share.publicShare': 'Public Share',
'journey.share.createLink': 'Create share link',
'journey.share.linkCreated': 'Share link created',
'journey.share.createFailed': 'Failed to create link',
'journey.share.copy': 'Copy',
'journey.share.copied': 'Copied!',
'journey.share.timeline': 'Timeline',
'journey.share.gallery': 'Gallery',
'journey.share.map': 'Map',
'journey.share.removeLink': 'Remove share link',
'journey.share.linkDeleted': 'Share link deleted',
'journey.share.deleteFailed': 'Failed to delete',
'journey.share.updateFailed': 'Failed to update',
// Journey — Settings Dialog
'journey.settings.title': 'Journey Settings',
'journey.settings.coverImage': 'Cover Image',
'journey.settings.changeCover': 'Change cover',
'journey.settings.addCover': 'Add cover image',
'journey.settings.name': 'Name',
'journey.settings.subtitle': 'Subtitle',
'journey.settings.subtitlePlaceholder': 'e.g. Thailand, Vietnam & Cambodia',
'journey.settings.delete': 'Delete',
'journey.settings.deleteJourney': 'Delete Journey',
'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.',
'journey.settings.saved': 'Settings saved',
'journey.settings.saveFailed': 'Failed to save',
'journey.settings.coverUpdated': 'Cover updated',
'journey.settings.coverFailed': 'Upload failed',
'journey.settings.failedToDelete': 'Failed to delete',
'journey.entries.deleteTitle': 'Delete Entry',
'journey.photosUploaded': '{count} photos uploaded',
'journey.photosAdded': '{count} photos added',
// Journey — Public Page
'journey.public.notFound': 'Not Found',
'journey.public.notFoundMessage': 'This journey doesn\'t exist or the link has expired.',
'journey.public.readOnly': 'Read-only · Public Journey',
'journey.public.tagline': 'Travel Resource & Exploration Kit',
'journey.public.sharedVia': 'Shared via',
'journey.public.madeWith': 'Made with',
// Journey — PDF Export
'journey.pdf.journeyBook': 'Journey Book',
'journey.pdf.madeWith': 'Made with TREK',
'journey.pdf.day': 'Day',
'journey.pdf.theEnd': 'The End',
'journey.pdf.saveAsPdf': 'Save as PDF',
'journey.pdf.pages': 'pages',
// Dashboard Mobile
'dashboard.greeting.morning': 'Good morning,',
'dashboard.greeting.afternoon': 'Good afternoon,',
'dashboard.greeting.evening': 'Good evening,',
'dashboard.mobile.liveNow': 'Live Now',
'dashboard.mobile.tripProgress': 'Trip progress',
'dashboard.mobile.daysLeft': '{count} days left',
'dashboard.mobile.places': 'Places',
'dashboard.mobile.buddies': 'Buddies',
'dashboard.mobile.newTrip': 'New Trip',
'dashboard.mobile.currency': 'Currency',
'dashboard.mobile.timezone': 'Timezone',
'dashboard.mobile.upcomingTrips': 'Upcoming Trips',
'dashboard.mobile.yourTrips': 'Your Trips',
'dashboard.mobile.trips': 'trips',
'dashboard.mobile.starts': 'Starts',
'dashboard.mobile.duration': 'Duration',
'dashboard.mobile.day': 'day',
'dashboard.mobile.days': 'days',
'dashboard.mobile.ongoing': 'Ongoing',
'dashboard.mobile.startsToday': 'Starts today',
'dashboard.mobile.tomorrow': 'Tomorrow',
'dashboard.mobile.inDays': 'In {count} days',
'dashboard.mobile.inMonths': 'In {count} months',
'dashboard.mobile.completed': 'Completed',
'dashboard.mobile.currencyConverter': 'Currency Converter',
// BottomNav & Profile
'nav.profile': 'Profile',
'nav.bottomSettings': 'Settings',
'nav.bottomAdmin': 'Admin Settings',
'nav.bottomLogout': 'Logout',
'nav.bottomAdminBadge': 'Admin',
// DayPlan Mobile
'dayplan.mobile.addPlace': 'Add Place',
'dayplan.mobile.searchPlaces': 'Search places...',
'dayplan.mobile.allAssigned': 'All places assigned',
'dayplan.mobile.noMatch': 'No match',
'dayplan.mobile.createNew': 'Create new place',
'admin.addons.catalog.journey.name': 'Journey',
'admin.addons.catalog.journey.description': 'Trip tracking & travel journal with check-ins, photos, and daily stories',
// OAuth scope groups
'oauth.scope.group.trips': 'Trips',
'oauth.scope.group.places': 'Places',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Packing',
'oauth.scope.group.todos': 'To-dos',
'oauth.scope.group.budget': 'Budget',
'oauth.scope.group.reservations': 'Reservations',
'oauth.scope.group.collab': 'Collaboration',
'oauth.scope.group.notifications': 'Notifications',
'oauth.scope.group.vacay': 'Vacation',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Weather',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'View trips & itineraries',
'oauth.scope.trips:read.description': 'Read trips, days, day notes, and members',
'oauth.scope.trips:write.label': 'Edit trips & itineraries',
'oauth.scope.trips:write.description': 'Create and update trips, days, notes, and manage members',
'oauth.scope.trips:delete.label': 'Delete trips',
'oauth.scope.trips:delete.description': 'Permanently delete entire trips — this action is irreversible',
'oauth.scope.trips:share.label': 'Manage share links',
'oauth.scope.trips:share.description': 'Create, update, and revoke public share links for trips',
'oauth.scope.places:read.label': 'View places & map data',
'oauth.scope.places:read.description': 'Read places, day assignments, tags, and categories',
'oauth.scope.places:write.label': 'Manage places',
'oauth.scope.places:write.description': 'Create, update, and delete places, assignments, and tags',
'oauth.scope.atlas:read.label': 'View Atlas',
'oauth.scope.atlas:read.description': 'Read visited countries, regions, and bucket list',
'oauth.scope.atlas:write.label': 'Manage Atlas',
'oauth.scope.atlas:write.description': 'Mark countries and regions visited, manage bucket list',
'oauth.scope.packing:read.label': 'View packing lists',
'oauth.scope.packing:read.description': 'Read packing items, bags, and category assignees',
'oauth.scope.packing:write.label': 'Manage packing lists',
'oauth.scope.packing:write.description': 'Add, update, delete, toggle, and reorder packing items and bags',
'oauth.scope.todos:read.label': 'View to-do lists',
'oauth.scope.todos:read.description': 'Read trip to-do items and category assignees',
'oauth.scope.todos:write.label': 'Manage to-do lists',
'oauth.scope.todos:write.description': 'Create, update, toggle, delete, and reorder to-do items',
'oauth.scope.budget:read.label': 'View budget',
'oauth.scope.budget:read.description': 'Read budget items and expense breakdown',
'oauth.scope.budget:write.label': 'Manage budget',
'oauth.scope.budget:write.description': 'Create, update, and delete budget items',
'oauth.scope.reservations:read.label': 'View reservations',
'oauth.scope.reservations:read.description': 'Read reservations and accommodation details',
'oauth.scope.reservations:write.label': 'Manage reservations',
'oauth.scope.reservations:write.description': 'Create, update, delete, and reorder reservations',
'oauth.scope.collab:read.label': 'View collaboration',
'oauth.scope.collab:read.description': 'Read collab notes, polls, and messages',
'oauth.scope.collab:write.label': 'Manage collaboration',
'oauth.scope.collab:write.description': 'Create, update, and delete collab notes, polls, and messages',
'oauth.scope.notifications:read.label': 'View notifications',
'oauth.scope.notifications:read.description': 'Read in-app notifications and unread counts',
'oauth.scope.notifications:write.label': 'Manage notifications',
'oauth.scope.notifications:write.description': 'Mark notifications as read and respond to them',
'oauth.scope.vacay:read.label': 'View vacation plans',
'oauth.scope.vacay:read.description': 'Read vacation planning data, entries, and stats',
'oauth.scope.vacay:write.label': 'Manage vacation plans',
'oauth.scope.vacay:write.description': 'Create and manage vacation entries, holidays, and team plans',
'oauth.scope.geo:read.label': 'Maps & geocoding',
'oauth.scope.geo:read.description': 'Search locations, resolve map URLs, and reverse geocode coordinates',
'oauth.scope.weather:read.label': 'Weather forecasts',
'oauth.scope.weather:read.description': 'Fetch weather forecasts for trip locations and dates',
} }
export default en export default en
+431 -17
View File
@@ -8,6 +8,8 @@ const es: Record<string, string> = {
'common.loading': 'Cargando...', 'common.loading': 'Cargando...',
'common.import': 'Importar', 'common.import': 'Importar',
'common.error': 'Error', 'common.error': 'Error',
'common.unknownError': 'Error desconocido',
'common.tooManyAttempts': 'Demasiados intentos. Inténtelo de nuevo más tarde.',
'common.back': 'Atrás', 'common.back': 'Atrás',
'common.all': 'Todo', 'common.all': 'Todo',
'common.close': 'Cerrar', 'common.close': 'Cerrar',
@@ -27,11 +29,17 @@ const es: Record<string, string> = {
'common.password': 'Contraseña', 'common.password': 'Contraseña',
'common.saving': 'Guardando...', 'common.saving': 'Guardando...',
'common.saved': 'Guardado', 'common.saved': 'Guardado',
'common.expand': 'Expandir',
'common.collapse': 'Contraer',
'trips.reminder': 'Recordatorio', 'trips.reminder': 'Recordatorio',
'trips.reminderNone': 'Ninguno', 'trips.reminderNone': 'Ninguno',
'trips.reminderDay': 'día', 'trips.reminderDay': 'día',
'trips.reminderDays': 'días', 'trips.reminderDays': 'días',
'trips.reminderCustom': 'Personalizado', 'trips.reminderCustom': 'Personalizado',
'trips.memberRemoved': '{username} eliminado',
'trips.memberRemoveError': 'Error al eliminar',
'trips.memberAdded': '{username} añadido',
'trips.memberAddError': 'Error al añadir',
'trips.reminderDaysBefore': 'días antes de la salida', 'trips.reminderDaysBefore': 'días antes de la salida',
'trips.reminderDisabledHint': 'Los recordatorios de viaje están desactivados. Actívalos en Admin > Configuración > Notificaciones.', 'trips.reminderDisabledHint': 'Los recordatorios de viaje están desactivados. Actívalos en Admin > Configuración > Notificaciones.',
'common.update': 'Actualizar', 'common.update': 'Actualizar',
@@ -180,9 +188,6 @@ const es: Record<string, string> = {
'admin.notifications.none': 'Desactivado', 'admin.notifications.none': 'Desactivado',
'admin.notifications.email': 'Correo (SMTP)', 'admin.notifications.email': 'Correo (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventos de notificación',
'admin.notifications.eventsHint': 'Elige qué eventos activan notificaciones para todos los usuarios.',
'admin.notifications.configureFirst': 'Configura primero los ajustes SMTP o webhook a continuación, luego activa los eventos.',
'admin.notifications.save': 'Guardar configuración de notificaciones', 'admin.notifications.save': 'Guardar configuración de notificaciones',
'admin.notifications.saved': 'Configuración de notificaciones guardada', 'admin.notifications.saved': 'Configuración de notificaciones guardada',
'admin.notifications.testWebhook': 'Enviar webhook de prueba', 'admin.notifications.testWebhook': 'Enviar webhook de prueba',
@@ -229,6 +234,7 @@ const es: Record<string, string> = {
'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configuración del cliente', 'settings.mcp.clientConfig': 'Configuración del cliente',
'settings.mcp.clientConfigHint': 'Reemplaza <your_token> con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).', 'settings.mcp.clientConfigHint': 'Reemplaza <your_token> con un token de la lista de abajo. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
'settings.mcp.clientConfigHintOAuth': 'Reemplaza <your_client_id> y <your_client_secret> con las credenciales del cliente OAuth 2.1 que creaste arriba. mcp-remote abrirá el navegador para completar la autorización la primera vez que te conectes. Es posible que debas ajustar la ruta de npx según tu sistema (p. ej. C:\\PROGRA~1\\nodejs\\npx.cmd en Windows).',
'settings.mcp.copy': 'Copiar', 'settings.mcp.copy': 'Copiar',
'settings.mcp.copied': '¡Copiado!', 'settings.mcp.copied': '¡Copiado!',
'settings.mcp.apiTokens': 'Tokens de API', 'settings.mcp.apiTokens': 'Tokens de API',
@@ -250,6 +256,48 @@ const es: Record<string, string> = {
'settings.mcp.toast.createError': 'Error al crear el token', 'settings.mcp.toast.createError': 'Error al crear el token',
'settings.mcp.toast.deleted': 'Token eliminado', 'settings.mcp.toast.deleted': 'Token eliminado',
'settings.mcp.toast.deleteError': 'Error al eliminar el token', 'settings.mcp.toast.deleteError': 'Error al eliminar el token',
'settings.mcp.apiTokensDeprecated': 'Los tokens de API están obsoletos y se eliminarán en una versión futura. Utilice los clientes OAuth 2.1 en su lugar.',
'settings.oauth.clients': 'Clientes OAuth 2.1',
'settings.oauth.clientsHint': 'Registre clientes OAuth 2.1 para que las aplicaciones MCP de terceros (Claude Web, Cursor, etc.) puedan conectarse sin tokens estáticos.',
'settings.oauth.createClient': 'Nuevo cliente',
'settings.oauth.noClients': 'No hay clientes OAuth registrados.',
'settings.oauth.clientId': 'ID de cliente',
'settings.oauth.clientSecret': 'Secreto de cliente',
'settings.oauth.deleteClient': 'Eliminar cliente',
'settings.oauth.deleteClientMessage': 'Este cliente y todas las sesiones activas se eliminarán permanentemente. Cualquier aplicación que lo use perderá el acceso inmediatamente.',
'settings.oauth.rotateSecret': 'Renovar secreto',
'settings.oauth.rotateSecretMessage': 'Se generará un nuevo secreto de cliente y todas las sesiones existentes se invalidarán de inmediato. Actualice su aplicación antes de cerrar este diálogo.',
'settings.oauth.rotateSecretConfirm': 'Renovar',
'settings.oauth.rotateSecretConfirming': 'Renovando…',
'settings.oauth.rotateSecretDoneTitle': 'Nuevo secreto generado',
'settings.oauth.rotateSecretDoneWarning': 'Este secreto solo se muestra una vez. Cópielo ahora y actualice su aplicación — todas las sesiones anteriores han sido invalidadas.',
'settings.oauth.activeSessions': 'Sesiones OAuth activas',
'settings.oauth.sessionScopes': 'Ámbitos',
'settings.oauth.sessionExpires': 'Expira',
'settings.oauth.revoke': 'Revocar',
'settings.oauth.revokeSession': 'Revocar sesión',
'settings.oauth.revokeSessionMessage': 'Esto revocará inmediatamente el acceso de esta sesión OAuth.',
'settings.oauth.modal.createTitle': 'Registrar cliente OAuth',
'settings.oauth.modal.presets': 'Ajustes rápidos',
'settings.oauth.modal.clientName': 'Nombre de la aplicación',
'settings.oauth.modal.clientNamePlaceholder': 'ej. Claude Web, Mi app MCP',
'settings.oauth.modal.redirectUris': 'URIs de redirección',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Un URI por línea. HTTPS obligatorio (localhost exento). Coincidencia exacta.',
'settings.oauth.modal.scopes': 'Ámbitos permitidos',
'settings.oauth.modal.scopesHint': 'list_trips y get_trip_summary siempre están disponibles — sin ámbito requerido. Permiten a la IA descubrir los IDs de viaje necesarios.',
'settings.oauth.modal.selectAll': 'Seleccionar todo',
'settings.oauth.modal.deselectAll': 'Deseleccionar todo',
'settings.oauth.modal.creating': 'Registrando…',
'settings.oauth.modal.create': 'Registrar cliente',
'settings.oauth.modal.createdTitle': 'Cliente registrado',
'settings.oauth.modal.createdWarning': 'El secreto del cliente solo se muestra una vez. Cópielo ahora — no se puede recuperar.',
'settings.oauth.toast.createError': 'Error al registrar el cliente OAuth',
'settings.oauth.toast.deleted': 'Cliente OAuth eliminado',
'settings.oauth.toast.deleteError': 'Error al eliminar el cliente OAuth',
'settings.oauth.toast.revoked': 'Sesión revocada',
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
'settings.account': 'Cuenta', 'settings.account': 'Cuenta',
'settings.about': 'Acerca de', 'settings.about': 'Acerca de',
'settings.about.reportBug': 'Reportar un error', 'settings.about.reportBug': 'Reportar un error',
@@ -363,6 +411,10 @@ const es: Record<string, string> = {
'login.mfaHint': 'Abre Google Authenticator, Authy u otra app TOTP.', 'login.mfaHint': 'Abre Google Authenticator, Authy u otra app TOTP.',
'login.mfaBack': '← Volver al inicio de sesión', 'login.mfaBack': '← Volver al inicio de sesión',
'login.mfaVerify': 'Verificar', 'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Enlace de invitación inválido o expirado',
'login.oidcFailed': 'Error de inicio de sesión OIDC',
'login.usernameRequired': 'El nombre de usuario es obligatorio',
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.oidc.tokenFailed': 'La autenticación falló.', 'login.oidc.tokenFailed': 'La autenticación falló.',
'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.', 'login.oidc.invalidState': 'Sesión no válida. Inténtalo de nuevo.',
'login.demoFailed': 'Falló el acceso a la demo', 'login.demoFailed': 'Falló el acceso a la demo',
@@ -397,7 +449,7 @@ const es: Record<string, string> = {
'admin.tabs.users': 'Usuarios', 'admin.tabs.users': 'Usuarios',
'admin.tabs.categories': 'Categorías', 'admin.tabs.categories': 'Categorías',
'admin.tabs.backup': 'Copia de seguridad', 'admin.tabs.backup': 'Copia de seguridad',
'admin.tabs.audit': 'Audit', 'admin.tabs.audit': 'Auditoría',
'admin.stats.users': 'Usuarios', 'admin.stats.users': 'Usuarios',
'admin.stats.trips': 'Viajes', 'admin.stats.trips': 'Viajes',
'admin.stats.places': 'Lugares', 'admin.stats.places': 'Lugares',
@@ -447,6 +499,17 @@ const es: Record<string, string> = {
'admin.tabs.settings': 'Ajustes', 'admin.tabs.settings': 'Ajustes',
'admin.allowRegistration': 'Permitir el registro', 'admin.allowRegistration': 'Permitir el registro',
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos', 'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Exigir autenticación en dos factores (2FA)', 'admin.requireMfa': 'Exigir autenticación en dos factores (2FA)',
'admin.requireMfaHint': 'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.', 'admin.requireMfaHint': 'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.',
'admin.apiKeys': 'Claves API', 'admin.apiKeys': 'Claves API',
@@ -525,9 +588,10 @@ const es: Record<string, string> = {
'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.', 'admin.weather.locationHint': 'El tiempo se basa en el primer lugar con coordenadas de cada día. Si no hay ningún lugar asignado a un día, se usa como referencia cualquier lugar de la lista.',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'Tokens MCP', 'admin.tabs.mcpTokens': 'Acceso MCP',
'admin.mcpTokens.title': 'Tokens MCP', 'admin.mcpTokens.title': 'Acceso MCP',
'admin.mcpTokens.subtitle': 'Gestionar tokens de API de todos los usuarios', 'admin.mcpTokens.subtitle': 'Gestionar sesiones OAuth y tokens de API de todos los usuarios',
'admin.mcpTokens.sectionTitle': 'Tokens de API',
'admin.mcpTokens.owner': 'Propietario', 'admin.mcpTokens.owner': 'Propietario',
'admin.mcpTokens.tokenName': 'Nombre del token', 'admin.mcpTokens.tokenName': 'Nombre del token',
'admin.mcpTokens.created': 'Creado', 'admin.mcpTokens.created': 'Creado',
@@ -539,6 +603,17 @@ const es: Record<string, string> = {
'admin.mcpTokens.deleteSuccess': 'Token eliminado', 'admin.mcpTokens.deleteSuccess': 'Token eliminado',
'admin.mcpTokens.deleteError': 'No se pudo eliminar el token', 'admin.mcpTokens.deleteError': 'No se pudo eliminar el token',
'admin.mcpTokens.loadError': 'No se pudieron cargar los tokens', 'admin.mcpTokens.loadError': 'No se pudieron cargar los tokens',
'admin.oauthSessions.sectionTitle': 'Sesiones OAuth',
'admin.oauthSessions.clientName': 'Cliente',
'admin.oauthSessions.owner': 'Propietario',
'admin.oauthSessions.scopes': 'Permisos',
'admin.oauthSessions.created': 'Creado',
'admin.oauthSessions.empty': 'No hay sesiones OAuth activas',
'admin.oauthSessions.revokeTitle': 'Revocar sesión',
'admin.oauthSessions.revokeMessage': 'Esto revocará la sesión OAuth inmediatamente. El cliente perderá el acceso MCP.',
'admin.oauthSessions.revokeSuccess': 'Sesión revocada',
'admin.oauthSessions.revokeError': 'No se pudo revocar la sesión',
'admin.oauthSessions.loadError': 'No se pudieron cargar las sesiones OAuth',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -633,6 +708,8 @@ const es: Record<string, string> = {
'vacay.companyHolidays': 'Festivos de empresa', 'vacay.companyHolidays': 'Festivos de empresa',
'vacay.companyHolidaysHint': 'Permitir marcar días festivos comunes de la empresa', 'vacay.companyHolidaysHint': 'Permitir marcar días festivos comunes de la empresa',
'vacay.companyHolidaysNoDeduct': 'Los festivos de empresa no descuentan días de vacaciones.', 'vacay.companyHolidaysNoDeduct': 'Los festivos de empresa no descuentan días de vacaciones.',
'vacay.weekStart': 'La semana comienza el',
'vacay.weekStartHint': 'Elige si la semana comienza el lunes o el domingo',
'vacay.carryOver': 'Arrastrar saldo', 'vacay.carryOver': 'Arrastrar saldo',
'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente', 'vacay.carryOverHint': 'Trasladar automáticamente los días restantes al año siguiente',
'vacay.sharing': 'Compartir', 'vacay.sharing': 'Compartir',
@@ -668,7 +745,7 @@ const es: Record<string, string> = {
'vacay.fuseInfo4': 'Ajustes como festivos y festivos de empresa se comparten.', 'vacay.fuseInfo4': 'Ajustes como festivos y festivos de empresa se comparten.',
'vacay.fuseInfo5': 'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.', 'vacay.fuseInfo5': 'La fusión puede disolverse en cualquier momento por cualquiera de las partes. Tus entradas se conservarán.',
'vacay.addCalendar': 'Añadir calendario', 'vacay.addCalendar': 'Añadir calendario',
'vacay.calendarColor': 'Color', 'vacay.calendarColor': 'Color del calendario',
'vacay.calendarLabel': 'Etiqueta', 'vacay.calendarLabel': 'Etiqueta',
'vacay.noCalendars': 'Sin calendarios', 'vacay.noCalendars': 'Sin calendarios',
@@ -846,6 +923,7 @@ const es: Record<string, string> = {
'inspector.files': 'Archivos', 'inspector.files': 'Archivos',
'inspector.filesCount': '{count} archivos', 'inspector.filesCount': '{count} archivos',
'inspector.removeFromDay': 'Quitar del día', 'inspector.removeFromDay': 'Quitar del día',
'inspector.remove': 'Eliminar',
'inspector.addToDay': 'Añadir al día', 'inspector.addToDay': 'Añadir al día',
'inspector.confirmedRes': 'Reserva confirmada', 'inspector.confirmedRes': 'Reserva confirmada',
'inspector.pendingRes': 'Reserva pendiente', 'inspector.pendingRes': 'Reserva pendiente',
@@ -885,7 +963,7 @@ const es: Record<string, string> = {
'reservations.type.car': 'Coche de alquiler', 'reservations.type.car': 'Coche de alquiler',
'reservations.type.cruise': 'Crucero', 'reservations.type.cruise': 'Crucero',
'reservations.type.event': 'Evento', 'reservations.type.event': 'Evento',
'reservations.type.tour': 'Tour', 'reservations.type.tour': 'Excursión',
'reservations.type.other': 'Otro', 'reservations.type.other': 'Otro',
'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?', 'reservations.confirm.delete': '¿Seguro que quieres eliminar la reserva "{name}"?',
'reservations.confirm.deleteTitle': '¿Eliminar reserva?', 'reservations.confirm.deleteTitle': '¿Eliminar reserva?',
@@ -969,6 +1047,7 @@ const es: Record<string, string> = {
'budget.totalBudget': 'Presupuesto total', 'budget.totalBudget': 'Presupuesto total',
'budget.byCategory': 'Por categoría', 'budget.byCategory': 'Por categoría',
'budget.editTooltip': 'Haz clic para editar', 'budget.editTooltip': 'Haz clic para editar',
'budget.linkedToReservation': 'Vinculado a una reserva — edite el nombre allí',
'budget.confirm.deleteCategory': '¿Seguro que quieres eliminar la categoría "{name}" con {count} entradas?', 'budget.confirm.deleteCategory': '¿Seguro que quieres eliminar la categoría "{name}" con {count} entradas?',
'budget.deleteCategory': 'Eliminar categoría', 'budget.deleteCategory': 'Eliminar categoría',
'budget.perPerson': 'Por persona', 'budget.perPerson': 'Por persona',
@@ -978,9 +1057,13 @@ const es: Record<string, string> = {
'budget.settlement': 'Liquidación', 'budget.settlement': 'Liquidación',
'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.', 'budget.settlementInfo': 'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
'budget.netBalances': 'Saldos netos', 'budget.netBalances': 'Saldos netos',
'budget.linkedToReservation': 'Vinculado a una reserva — edita el nombre allí',
// Files // Files
'files.title': 'Archivos', 'files.title': 'Archivos',
'files.pageTitle': 'Archivos y documentos',
'files.subtitle': '{count} archivos para {trip}',
'files.downloadPdf': 'Descargar PDF',
'files.count': '{count} archivos', 'files.count': '{count} archivos',
'files.countSingular': '1 archivo', 'files.countSingular': '1 archivo',
'files.uploaded': '{count} archivos subidos', 'files.uploaded': '{count} archivos subidos',
@@ -1045,7 +1128,9 @@ const es: Record<string, string> = {
'packing.template': 'Plantilla', 'packing.template': 'Plantilla',
'packing.templateApplied': '{count} artículos añadidos desde plantilla', 'packing.templateApplied': '{count} artículos añadidos desde plantilla',
'packing.templateError': 'Error al aplicar plantilla', 'packing.templateError': 'Error al aplicar plantilla',
'packing.assignUser': 'Asignar usuario', 'packing.saveAsTemplate': 'Guardar como plantilla',
'packing.templateName': 'Nombre de la plantilla',
'packing.templateSaved': 'Lista de equipaje guardada como plantilla',
'packing.noMembers': 'Sin miembros', 'packing.noMembers': 'Sin miembros',
'packing.bags': 'Equipaje', 'packing.bags': 'Equipaje',
'packing.noBag': 'Sin asignar', 'packing.noBag': 'Sin asignar',
@@ -1202,6 +1287,13 @@ const es: Record<string, string> = {
'backup.keep.forever': 'Conservar para siempre', 'backup.keep.forever': 'Conservar para siempre',
// Photos // Photos
'photos.title': 'Fotos',
'photos.subtitle': '{count} fotos para {trip}',
'photos.dropHere': 'Suelta fotos aquí...',
'photos.dropHereActive': 'Suelta fotos aquí',
'photos.captionForAll': 'Leyenda (para todos)',
'photos.captionPlaceholder': 'Leyenda opcional...',
'photos.addCaption': 'Añadir leyenda...',
'photos.allDays': 'Todos los días', 'photos.allDays': 'Todos los días',
'photos.noPhotos': 'Aún no hay fotos', 'photos.noPhotos': 'Aún no hay fotos',
'photos.uploadHint': 'Sube y organiza las fotos compartidas de este viaje', 'photos.uploadHint': 'Sube y organiza las fotos compartidas de este viaje',
@@ -1209,6 +1301,12 @@ const es: Record<string, string> = {
'photos.linkPlace': 'Vincular lugar', 'photos.linkPlace': 'Vincular lugar',
'photos.noPlace': 'Sin lugar', 'photos.noPlace': 'Sin lugar',
'photos.uploadN': 'Subida de {n} foto(s)', 'photos.uploadN': 'Subida de {n} foto(s)',
'photos.linkDay': 'Vincular día',
'photos.noDay': 'Ningún día',
'photos.dayLabel': 'Día {number}',
'photos.photoSelected': 'Foto seleccionada',
'photos.photosSelected': 'Fotos seleccionadas',
'photos.fileTypeHint': 'JPG, PNG, WebP · máx. 10 MB · hasta 30 fotos',
'admin.addons.catalog.memories.name': 'Fotos (Immich)', 'admin.addons.catalog.memories.name': 'Fotos (Immich)',
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich', 'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
'admin.addons.catalog.mcp.name': 'MCP', 'admin.addons.catalog.mcp.name': 'MCP',
@@ -1251,6 +1349,7 @@ const es: Record<string, string> = {
'planner.routeCalculated': 'Ruta calculada', 'planner.routeCalculated': 'Ruta calculada',
'planner.routeCalcFailed': 'No se pudo calcular la ruta', 'planner.routeCalcFailed': 'No se pudo calcular la ruta',
'planner.routeError': 'Error al calcular la ruta', 'planner.routeError': 'Error al calcular la ruta',
'planner.icsExportFailed': 'Error al exportar ICS',
'planner.routeOptimized': 'Ruta optimizada', 'planner.routeOptimized': 'Ruta optimizada',
'planner.reservationUpdated': 'Reserva actualizada', 'planner.reservationUpdated': 'Reserva actualizada',
'planner.reservationAdded': 'Reserva añadida', 'planner.reservationAdded': 'Reserva añadida',
@@ -1325,8 +1424,8 @@ const es: Record<string, string> = {
'day.hotelDayRange': 'Aplicar a los días', 'day.hotelDayRange': 'Aplicar a los días',
'day.noPlacesForHotel': 'Añade primero lugares al viaje', 'day.noPlacesForHotel': 'Añade primero lugares al viaje',
'day.allDays': 'Todos', 'day.allDays': 'Todos',
'day.checkIn': 'Check-in', 'day.checkIn': 'Registro de entrada',
'day.checkOut': 'Check-out', 'day.checkOut': 'Registro de salida',
'day.confirmation': 'Confirmación', 'day.confirmation': 'Confirmación',
'day.editAccommodation': 'Editar alojamiento', 'day.editAccommodation': 'Editar alojamiento',
'day.reservations': 'Reservas', 'day.reservations': 'Reservas',
@@ -1335,6 +1434,7 @@ const es: Record<string, string> = {
'memories.title': 'Fotos', 'memories.title': 'Fotos',
'memories.notConnected': 'Immich no conectado', 'memories.notConnected': 'Immich no conectado',
'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.', 'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
'memories.notConnectedMultipleHint': 'Conecta alguno de estos proveedores de fotos: {provider_names} en Configuración para poder añadir fotos a este viaje.',
'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.', 'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.',
'memories.noPhotos': 'No se encontraron fotos', 'memories.noPhotos': 'No se encontraron fotos',
'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.', 'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.',
@@ -1345,26 +1445,38 @@ const es: Record<string, string> = {
'memories.reviewTitle': 'Revisar tus fotos', 'memories.reviewTitle': 'Revisar tus fotos',
'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.', 'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.',
'memories.shareCount': 'Compartir {count} fotos', 'memories.shareCount': 'Compartir {count} fotos',
'memories.immichUrl': 'URL del servidor Immich', 'memories.providerUrl': 'URL del servidor',
'memories.immichApiKey': 'Clave API', 'memories.providerApiKey': 'Clave API',
'memories.providerUsername': 'Nombre de usuario',
'memories.providerPassword': 'Contraseña',
'memories.providerOTP': 'Código MFA (si está habilitado)',
'memories.skipSSLVerification': 'Omitir verificación del certificado SSL',
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo',
'memories.testConnection': 'Probar conexión', 'memories.testConnection': 'Probar conexión',
'memories.testFirst': 'Probar conexión primero', 'memories.testFirst': 'Probar conexión primero',
'memories.connected': 'Conectado', 'memories.connected': 'Conectado',
'memories.disconnected': 'No conectado', 'memories.disconnected': 'No conectado',
'memories.connectionSuccess': 'Conectado a Immich', 'memories.connectionSuccess': 'Conectado a Immich',
'memories.connectionError': 'No se pudo conectar a Immich', 'memories.connectionError': 'No se pudo conectar a Immich',
'memories.saved': 'Configuración de Immich guardada', 'memories.saved': 'Configuración de {provider_name} guardada',
'memories.providerDisconnectedBanner': 'Se perdió la conexión con {provider_name}. Vuelve a conectar en Configuración para ver las fotos.',
'memories.saveError': 'No se pudieron guardar los ajustes de {provider_name}',
'memories.saveRouteNotConfigured': 'La ruta de guardado no está configurada para este proveedor',
'memories.testRouteNotConfigured': 'La ruta de prueba no está configurada para este proveedor',
'memories.fillRequiredFields': 'Por favor complete todos los campos requeridos',
'memories.oldest': 'Más antiguas', 'memories.oldest': 'Más antiguas',
'memories.newest': 'Más recientes', 'memories.newest': 'Más recientes',
'memories.allLocations': 'Todas las ubicaciones', 'memories.allLocations': 'Todas las ubicaciones',
'memories.addPhotos': 'Añadir fotos', 'memories.addPhotos': 'Añadir fotos',
'memories.linkAlbum': 'Vincular álbum', 'memories.linkAlbum': 'Vincular álbum',
'memories.selectAlbum': 'Seleccionar álbum de Immich', 'memories.selectAlbum': 'Seleccionar álbum de Immich',
'memories.selectAlbumMultiple': 'Seleccionar álbum',
'memories.noAlbums': 'No se encontraron álbumes', 'memories.noAlbums': 'No se encontraron álbumes',
'memories.syncAlbum': 'Sincronizar álbum', 'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular', 'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos', 'memories.photos': 'fotos',
'memories.selectPhotos': 'Seleccionar fotos de Immich', 'memories.selectPhotos': 'Seleccionar fotos de Immich',
'memories.selectPhotosMultiple': 'Seleccionar fotos',
'memories.selectHint': 'Toca las fotos para seleccionarlas.', 'memories.selectHint': 'Toca las fotos para seleccionarlas.',
'memories.selected': 'seleccionado(s)', 'memories.selected': 'seleccionado(s)',
'memories.addSelected': 'Añadir {count} fotos', 'memories.addSelected': 'Añadir {count} fotos',
@@ -1479,8 +1591,8 @@ const es: Record<string, string> = {
'reservations.meta.trainNumber': 'N° de tren', 'reservations.meta.trainNumber': 'N° de tren',
'reservations.meta.platform': 'Andén', 'reservations.meta.platform': 'Andén',
'reservations.meta.seat': 'Asiento', 'reservations.meta.seat': 'Asiento',
'reservations.meta.checkIn': 'Check-in', 'reservations.meta.checkIn': 'Registro de entrada',
'reservations.meta.checkOut': 'Check-out', 'reservations.meta.checkOut': 'Registro de salida',
'reservations.meta.linkAccommodation': 'Alojamiento', 'reservations.meta.linkAccommodation': 'Alojamiento',
'reservations.meta.pickAccommodation': 'Vincular con alojamiento', 'reservations.meta.pickAccommodation': 'Vincular con alojamiento',
'reservations.meta.noAccommodation': 'Ninguno', 'reservations.meta.noAccommodation': 'Ninguno',
@@ -1575,6 +1687,8 @@ const es: Record<string, string> = {
'notifications.markUnread': 'Marcar como no leída', 'notifications.markUnread': 'Marcar como no leída',
'notifications.delete': 'Eliminar', 'notifications.delete': 'Eliminar',
'notifications.system': 'Sistema', 'notifications.system': 'Sistema',
'notifications.synologySessionCleared.title': 'Synology Photos desconectado',
'notifications.synologySessionCleared.text': 'Tu servidor o cuenta ha cambiado — ve a Configuración para probar la conexión de nuevo.',
'memories.error.loadAlbums': 'Error al cargar los álbumes', 'memories.error.loadAlbums': 'Error al cargar los álbumes',
'memories.error.linkAlbum': 'Error al vincular el álbum', 'memories.error.linkAlbum': 'Error al vincular el álbum',
'memories.error.unlinkAlbum': 'Error al desvincular el álbum', 'memories.error.unlinkAlbum': 'Error al desvincular el álbum',
@@ -1697,6 +1811,306 @@ const es: Record<string, string> = {
'notif.generic.text': 'Tienes una nueva notificación', 'notif.generic.text': 'Tienes una nueva notificación',
'notif.dev.unknown_event.title': '[DEV] Evento desconocido', 'notif.dev.unknown_event.title': '[DEV] Evento desconocido',
'notif.dev.unknown_event.text': 'El tipo de evento "{event}" no está registrado en EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'El tipo de evento "{event}" no está registrado en EVENT_NOTIFICATION_CONFIG',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'justo ahora',
'common.hoursAgo': 'hace {count}h',
'common.daysAgo': 'hace {count}d',
'budget.linkedToReservation': 'Vinculado a una reserva — edita el nombre allí',
'packing.saveAsTemplate': 'Guardar como plantilla',
'packing.templateName': 'Nombre de la plantilla',
'packing.templateSaved': 'Lista de equipaje guardada como plantilla',
'memories.notConnectedMultipleHint': 'Conecta cualquiera de estos proveedores de fotos: {provider_names} en Ajustes para poder añadir fotos a este viaje.',
'memories.providerUrl': 'URL del servidor',
'memories.providerApiKey': 'Clave API',
'memories.providerUsername': 'Nombre de usuario',
'memories.providerPassword': 'Contraseña',
'memories.saveError': 'No se pudo guardar la configuración de {provider_name}',
'memories.selectAlbumMultiple': 'Seleccionar álbum',
'memories.selectPhotosMultiple': 'Seleccionar fotos',
'journey.title': 'Travesía',
'journey.subtitle': 'Registra tus viajes en tiempo real',
'journey.new': 'Nueva travesía',
'journey.create': 'Crear',
'journey.titlePlaceholder': '¿A dónde vas?',
'journey.empty': 'Aún no hay travesías',
'journey.emptyHint': 'Empieza a documentar tu próximo viaje',
'journey.deleted': 'Travesía eliminada',
'journey.createError': 'No se pudo crear la travesía',
'journey.deleteError': 'No se pudo eliminar la travesía',
'journey.deleteConfirmTitle': 'Eliminar',
'journey.deleteConfirmMessage': '¿Eliminar "{title}"? Esta acción no se puede deshacer.',
'journey.deleteConfirmGeneric': '¿Estás seguro de que quieres eliminar esto?',
'journey.notFound': 'Travesía no encontrada',
'journey.photos': 'Fotos',
'journey.timelineEmpty': 'Aún no hay paradas',
'journey.timelineEmptyHint': 'Añade un registro de ubicación o escribe una entrada de diario para empezar',
'journey.status.draft': 'Borrador',
'journey.status.active': 'Activa',
'journey.status.completed': 'Completada',
'journey.status.upcoming': 'Próxima',
'journey.checkin.add': 'Registrar ubicación',
'journey.checkin.namePlaceholder': 'Nombre del lugar',
'journey.checkin.notesPlaceholder': 'Notas (opcional)',
'journey.checkin.save': 'Guardar',
'journey.checkin.error': 'No se pudo guardar el registro',
'journey.entry.add': 'Diario',
'journey.entry.edit': 'Editar entrada',
'journey.entry.titlePlaceholder': 'Título (opcional)',
'journey.entry.bodyPlaceholder': '¿Qué pasó hoy?',
'journey.entry.save': 'Guardar',
'journey.entry.error': 'No se pudo guardar la entrada',
'journey.photo.add': 'Foto',
'journey.photo.uploadError': 'Error al subir',
'journey.share.share': 'Compartir',
'journey.share.public': 'Público',
'journey.share.linkCopied': 'Enlace público copiado',
'journey.share.disabled': 'Compartir público desactivado',
'journey.editor.titlePlaceholder': 'Dale un nombre a este momento...',
'journey.editor.bodyPlaceholder': 'Cuenta la historia de este día...',
'journey.editor.placePlaceholder': 'Ubicación (opcional)',
'journey.editor.tagsPlaceholder': 'Etiquetas: joya oculta, mejor comida, hay que volver...',
'journey.visibility.private': 'Privado',
'journey.visibility.shared': 'Compartido',
'journey.visibility.public': 'Público',
'journey.emptyState.title': 'Tu historia empieza aquí',
'journey.emptyState.subtitle': 'Registra una ubicación o escribe tu primera entrada de diario',
'journey.frontpage.subtitle': 'Convierte tus viajes en historias que nunca olvidarás',
'journey.frontpage.createJourney': 'Crear travesía',
'journey.frontpage.activeJourney': 'Travesía activa',
'journey.frontpage.allJourneys': 'Todas las travesías',
'journey.frontpage.journeys': 'travesías',
'journey.frontpage.createNew': 'Crear una nueva travesía',
'journey.frontpage.createNewSub': 'Elige viajes, escribe historias, comparte tus aventuras',
'journey.frontpage.live': 'En vivo',
'journey.frontpage.synced': 'Sincronizado',
'journey.frontpage.continueWriting': 'Seguir escribiendo',
'journey.frontpage.updated': 'Actualizado {time}',
'journey.frontpage.suggestionLabel': 'El viaje acaba de terminar',
'journey.frontpage.suggestionText': 'Convierte <strong>{title}</strong> en una travesía',
'journey.frontpage.dismiss': 'Descartar',
'journey.frontpage.journeyName': 'Nombre de la travesía',
'journey.frontpage.namePlaceholder': 'p. ej. Sudeste Asiático 2026',
'journey.frontpage.selectTrips': 'Seleccionar viajes',
'journey.frontpage.tripsSelected': 'viajes seleccionados',
'journey.frontpage.trips': 'viajes',
'journey.frontpage.placesImported': 'lugares serán importados',
'journey.frontpage.places': 'lugares',
'journey.detail.backToJourney': 'Volver a la travesía',
'journey.detail.syncedWithTrips': 'Sincronizado con viajes',
'journey.detail.addEntry': 'Añadir entrada',
'journey.detail.newEntry': 'Nueva entrada',
'journey.detail.editEntry': 'Editar entrada',
'journey.detail.noEntries': 'Aún no hay entradas',
'journey.detail.noEntriesHint': 'Añade un viaje para empezar con entradas preliminares',
'journey.detail.noPhotos': 'Aún no hay fotos',
'journey.detail.noPhotosHint': 'Sube fotos a las entradas o explora tu biblioteca de Immich/Synology',
'journey.detail.journeyStats': 'Estadísticas de la travesía',
'journey.detail.syncedTrips': 'Viajes sincronizados',
'journey.detail.noTripsLinked': 'Aún no hay viajes vinculados',
'journey.detail.contributors': 'Colaboradores',
'journey.detail.readMore': 'Leer más',
'journey.detail.prosCons': 'Pros y contras',
'journey.stats.days': 'Días',
'journey.stats.cities': 'Ciudades',
'journey.stats.entries': 'Entradas',
'journey.stats.photos': 'Fotos',
'journey.stats.places': 'Lugares',
'journey.verdict.lovedIt': 'Me encantó',
'journey.verdict.couldBeBetter': 'Podría mejorar',
'journey.synced.places': 'lugares',
'journey.synced.synced': 'sincronizado',
'journey.editor.uploadPhotos': 'Subir fotos',
'journey.editor.fromGallery': 'Desde galería',
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
'journey.editor.writeStory': 'Escribe tu historia...',
'journey.editor.prosCons': 'Pros y contras',
'journey.editor.pros': 'Pros',
'journey.editor.cons': 'Contras',
'journey.editor.proPlaceholder': 'Algo genial...',
'journey.editor.conPlaceholder': 'No tan genial...',
'journey.editor.addAnother': 'Añadir otro',
'journey.editor.date': 'Fecha',
'journey.editor.location': 'Ubicación',
'journey.editor.searchLocation': 'Buscar ubicación...',
'journey.editor.mood': 'Estado de ánimo',
'journey.editor.weather': 'Clima',
'journey.editor.photoFirst': '1º',
'journey.editor.makeFirst': 'Hacer 1º',
'journey.mood.amazing': 'Increíble',
'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutral',
'journey.mood.rough': 'Difícil',
'journey.weather.sunny': 'Soleado',
'journey.weather.partly': 'Parcialmente nublado',
'journey.weather.cloudy': 'Nublado',
'journey.weather.rainy': 'Lluvioso',
'journey.weather.stormy': 'Tormentoso',
'journey.weather.cold': 'Nevado',
'journey.trips.linkTrip': 'Vincular viaje',
'journey.trips.searchTrip': 'Buscar viaje',
'journey.trips.searchPlaceholder': 'Nombre del viaje o destino...',
'journey.trips.noTripsAvailable': 'No hay viajes disponibles',
'journey.trips.link': 'Vincular',
'journey.trips.tripLinked': 'Viaje vinculado',
'journey.trips.linkFailed': 'No se pudo vincular el viaje',
'journey.trips.addTrip': 'Añadir viaje',
'journey.trips.unlinkTrip': 'Desvincular viaje',
'journey.trips.unlinkMessage': '¿Desvincular "{title}"? Todas las entradas y fotos sincronizadas de este viaje se eliminarán permanentemente. Esta acción no se puede deshacer.',
'journey.trips.unlink': 'Desvincular',
'journey.trips.tripUnlinked': 'Viaje desvinculado',
'journey.trips.unlinkFailed': 'No se pudo desvincular el viaje',
'journey.trips.noTripsLinkedSettings': 'No hay viajes vinculados',
'journey.contributors.invite': 'Invitar colaborador',
'journey.contributors.searchUser': 'Buscar usuario',
'journey.contributors.searchPlaceholder': 'Nombre de usuario o correo...',
'journey.contributors.noUsers': 'No se encontraron usuarios',
'journey.contributors.role': 'Rol',
'journey.contributors.added': 'Colaborador añadido',
'journey.contributors.addFailed': 'No se pudo añadir al colaborador',
'journey.share.publicShare': 'Compartir público',
'journey.share.createLink': 'Crear enlace para compartir',
'journey.share.linkCreated': 'Enlace para compartir creado',
'journey.share.createFailed': 'No se pudo crear el enlace',
'journey.share.copy': 'Copiar',
'journey.share.copied': '¡Copiado!',
'journey.share.timeline': 'Cronología',
'journey.share.gallery': 'Galería',
'journey.share.map': 'Mapa',
'journey.share.removeLink': 'Eliminar enlace para compartir',
'journey.share.linkDeleted': 'Enlace para compartir eliminado',
'journey.share.deleteFailed': 'No se pudo eliminar',
'journey.share.updateFailed': 'No se pudo actualizar',
'journey.settings.title': 'Ajustes de la travesía',
'journey.settings.coverImage': 'Imagen de portada',
'journey.settings.changeCover': 'Cambiar portada',
'journey.settings.addCover': 'Añadir imagen de portada',
'journey.settings.name': 'Nombre',
'journey.settings.subtitle': 'Subtítulo',
'journey.settings.subtitlePlaceholder': 'p. ej. Tailandia, Vietnam y Camboya',
'journey.settings.delete': 'Eliminar',
'journey.settings.deleteJourney': 'Eliminar travesía',
'journey.settings.deleteMessage': '¿Eliminar "{title}"? Todas las entradas y fotos se perderán.',
'journey.settings.saved': 'Ajustes guardados',
'journey.settings.saveFailed': 'No se pudo guardar',
'journey.settings.coverUpdated': 'Portada actualizada',
'journey.settings.coverFailed': 'Error al subir',
'journey.settings.failedToDelete': 'Error al eliminar',
'journey.entries.deleteTitle': 'Eliminar entrada',
'journey.photosUploaded': '{count} fotos subidas',
'journey.photosAdded': '{count} fotos añadidas',
'journey.public.notFound': 'No encontrado',
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
'journey.public.readOnly': 'Solo lectura · Travesía pública',
'journey.public.tagline': 'Kit de recursos y exploración de viajes',
'journey.public.sharedVia': 'Compartido mediante',
'journey.public.madeWith': 'Hecho con',
'journey.pdf.journeyBook': 'Libro de travesía',
'journey.pdf.madeWith': 'Hecho con TREK',
'journey.pdf.day': 'Día',
'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Guardar como PDF',
'journey.pdf.pages': 'páginas',
'dashboard.greeting.morning': 'Buenos días,',
'dashboard.greeting.afternoon': 'Buenas tardes,',
'dashboard.greeting.evening': 'Buenas noches,',
'dashboard.mobile.liveNow': 'En vivo ahora',
'dashboard.mobile.tripProgress': 'Progreso del viaje',
'dashboard.mobile.daysLeft': '{count} días restantes',
'dashboard.mobile.places': 'Lugares',
'dashboard.mobile.buddies': 'Compañeros',
'dashboard.mobile.newTrip': 'Nuevo viaje',
'dashboard.mobile.currency': 'Moneda',
'dashboard.mobile.timezone': 'Zona horaria',
'dashboard.mobile.upcomingTrips': 'Próximos viajes',
'dashboard.mobile.yourTrips': 'Tus viajes',
'dashboard.mobile.trips': 'viajes',
'dashboard.mobile.starts': 'Comienza',
'dashboard.mobile.duration': 'Duración',
'dashboard.mobile.day': 'día',
'dashboard.mobile.days': 'días',
'dashboard.mobile.ongoing': 'En curso',
'dashboard.mobile.startsToday': 'Comienza hoy',
'dashboard.mobile.tomorrow': 'Mañana',
'dashboard.mobile.inDays': 'En {count} días',
'dashboard.mobile.inMonths': 'En {count} meses',
'dashboard.mobile.completed': 'Completado',
'dashboard.mobile.currencyConverter': 'Conversor de monedas',
'nav.profile': 'Perfil',
'nav.bottomSettings': 'Ajustes',
'nav.bottomAdmin': 'Administración',
'nav.bottomLogout': 'Cerrar sesión',
'nav.bottomAdminBadge': 'Admin',
'dayplan.mobile.addPlace': 'Añadir lugar',
'dayplan.mobile.searchPlaces': 'Buscar lugares...',
'dayplan.mobile.allAssigned': 'Todos los lugares asignados',
'dayplan.mobile.noMatch': 'Sin coincidencias',
'dayplan.mobile.createNew': 'Crear nuevo lugar',
'admin.addons.catalog.journey.name': 'Travesía',
'admin.addons.catalog.journey.description': 'Seguimiento de viajes y diario de viajero con registros de ubicación, fotos e historias diarias',
// OAuth scope groups
'oauth.scope.group.trips': 'Viajes',
'oauth.scope.group.places': 'Lugares',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Equipaje',
'oauth.scope.group.todos': 'Tareas',
'oauth.scope.group.budget': 'Presupuesto',
'oauth.scope.group.reservations': 'Reservas',
'oauth.scope.group.collab': 'Colaboración',
'oauth.scope.group.notifications': 'Notificaciones',
'oauth.scope.group.vacay': 'Vacaciones',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Clima',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Ver viajes e itinerarios',
'oauth.scope.trips:read.description': 'Leer viajes, días, notas y miembros',
'oauth.scope.trips:write.label': 'Editar viajes e itinerarios',
'oauth.scope.trips:write.description': 'Crear y actualizar viajes, días, notas y gestionar miembros',
'oauth.scope.trips:delete.label': 'Eliminar viajes',
'oauth.scope.trips:delete.description': 'Eliminar viajes permanentemente — esta acción es irreversible',
'oauth.scope.trips:share.label': 'Gestionar enlaces de compartir',
'oauth.scope.trips:share.description': 'Crear, actualizar y revocar enlaces públicos de viaje',
'oauth.scope.places:read.label': 'Ver lugares y datos del mapa',
'oauth.scope.places:read.description': 'Leer lugares, asignaciones de días, etiquetas y categorías',
'oauth.scope.places:write.label': 'Gestionar lugares',
'oauth.scope.places:write.description': 'Crear, actualizar y eliminar lugares, asignaciones y etiquetas',
'oauth.scope.atlas:read.label': 'Ver Atlas',
'oauth.scope.atlas:read.description': 'Leer países visitados, regiones y lista de deseos',
'oauth.scope.atlas:write.label': 'Gestionar Atlas',
'oauth.scope.atlas:write.description': 'Marcar países y regiones como visitados, gestionar lista de deseos',
'oauth.scope.packing:read.label': 'Ver listas de equipaje',
'oauth.scope.packing:read.description': 'Leer artículos, maletas y responsables de categoría',
'oauth.scope.packing:write.label': 'Gestionar listas de equipaje',
'oauth.scope.packing:write.description': 'Agregar, actualizar, eliminar, marcar y reordenar artículos y maletas',
'oauth.scope.todos:read.label': 'Ver listas de tareas',
'oauth.scope.todos:read.description': 'Leer tareas del viaje y responsables de categoría',
'oauth.scope.todos:write.label': 'Gestionar listas de tareas',
'oauth.scope.todos:write.description': 'Crear, actualizar, marcar, eliminar y reordenar tareas',
'oauth.scope.budget:read.label': 'Ver presupuesto',
'oauth.scope.budget:read.description': 'Leer partidas de presupuesto y desglose de gastos',
'oauth.scope.budget:write.label': 'Gestionar presupuesto',
'oauth.scope.budget:write.description': 'Crear, actualizar y eliminar partidas de presupuesto',
'oauth.scope.reservations:read.label': 'Ver reservas',
'oauth.scope.reservations:read.description': 'Leer reservas y detalles de alojamiento',
'oauth.scope.reservations:write.label': 'Gestionar reservas',
'oauth.scope.reservations:write.description': 'Crear, actualizar, eliminar y reordenar reservas',
'oauth.scope.collab:read.label': 'Ver colaboración',
'oauth.scope.collab:read.description': 'Leer notas colaborativas, encuestas y mensajes',
'oauth.scope.collab:write.label': 'Gestionar colaboración',
'oauth.scope.collab:write.description': 'Crear, actualizar y eliminar notas, encuestas y mensajes',
'oauth.scope.notifications:read.label': 'Ver notificaciones',
'oauth.scope.notifications:read.description': 'Leer notificaciones y conteos no leídos',
'oauth.scope.notifications:write.label': 'Gestionar notificaciones',
'oauth.scope.notifications:write.description': 'Marcar notificaciones como leídas y responderlas',
'oauth.scope.vacay:read.label': 'Ver planes de vacaciones',
'oauth.scope.vacay:read.description': 'Leer datos de planificación, entradas y estadísticas de vacaciones',
'oauth.scope.vacay:write.label': 'Gestionar planes de vacaciones',
'oauth.scope.vacay:write.description': 'Crear y gestionar entradas de vacaciones, festivos y planes de equipo',
'oauth.scope.geo:read.label': 'Mapas y geocodificación',
'oauth.scope.geo:read.description': 'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas',
'oauth.scope.weather:read.label': 'Previsiones meteorológicas',
'oauth.scope.weather:read.description': 'Obtener previsiones meteorológicas para lugares y fechas del viaje',
} }
export default es export default es
+424 -10
View File
@@ -8,6 +8,8 @@ const fr: Record<string, string> = {
'common.loading': 'Chargement…', 'common.loading': 'Chargement…',
'common.import': 'Importer', 'common.import': 'Importer',
'common.error': 'Erreur', 'common.error': 'Erreur',
'common.unknownError': 'Erreur inconnue',
'common.tooManyAttempts': 'Trop de tentatives. Veuillez réessayer plus tard.',
'common.back': 'Retour', 'common.back': 'Retour',
'common.all': 'Tout', 'common.all': 'Tout',
'common.close': 'Fermer', 'common.close': 'Fermer',
@@ -27,6 +29,12 @@ const fr: Record<string, string> = {
'common.password': 'Mot de passe', 'common.password': 'Mot de passe',
'common.saving': 'Enregistrement…', 'common.saving': 'Enregistrement…',
'common.saved': 'Enregistré', 'common.saved': 'Enregistré',
'trips.memberRemoved': '{username} supprimé',
'trips.memberRemoveError': 'Échec de la suppression',
'trips.memberAdded': '{username} ajouté',
'trips.memberAddError': "Échec de l'ajout",
'common.expand': 'Développer',
'common.collapse': 'Réduire',
'trips.reminder': 'Rappel', 'trips.reminder': 'Rappel',
'trips.reminderNone': 'Aucun', 'trips.reminderNone': 'Aucun',
'trips.reminderDay': 'jour', 'trips.reminderDay': 'jour',
@@ -179,9 +187,6 @@ const fr: Record<string, string> = {
'admin.notifications.none': 'Désactivé', 'admin.notifications.none': 'Désactivé',
'admin.notifications.email': 'E-mail (SMTP)', 'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Événements de notification',
'admin.notifications.eventsHint': 'Choisissez quels événements déclenchent des notifications pour tous les utilisateurs.',
'admin.notifications.configureFirst': 'Configurez d\'abord les paramètres SMTP ou webhook ci-dessous, puis activez les événements.',
'admin.notifications.save': 'Enregistrer les paramètres de notification', 'admin.notifications.save': 'Enregistrer les paramètres de notification',
'admin.notifications.saved': 'Paramètres de notification enregistrés', 'admin.notifications.saved': 'Paramètres de notification enregistrés',
'admin.notifications.testWebhook': 'Envoyer un webhook de test', 'admin.notifications.testWebhook': 'Envoyer un webhook de test',
@@ -228,6 +233,7 @@ const fr: Record<string, string> = {
'settings.mcp.endpoint': 'Point de terminaison MCP', 'settings.mcp.endpoint': 'Point de terminaison MCP',
'settings.mcp.clientConfig': 'Configuration du client', 'settings.mcp.clientConfig': 'Configuration du client',
'settings.mcp.clientConfigHint': 'Remplacez <your_token> par un token API de la liste ci-dessous. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).', 'settings.mcp.clientConfigHint': 'Remplacez <your_token> par un token API de la liste ci-dessous. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).',
'settings.mcp.clientConfigHintOAuth': 'Remplacez <your_client_id> et <your_client_secret> par les identifiants affichés dans le client OAuth 2.1 créé ci-dessus. mcp-remote ouvrira votre navigateur pour finaliser l\'autorisation lors de la première connexion. Le chemin vers npx devra peut-être être ajusté selon votre système (ex. C:\\PROGRA~1\\nodejs\\npx.cmd sous Windows).',
'settings.mcp.copy': 'Copier', 'settings.mcp.copy': 'Copier',
'settings.mcp.copied': 'Copié !', 'settings.mcp.copied': 'Copié !',
'settings.mcp.apiTokens': 'Tokens API', 'settings.mcp.apiTokens': 'Tokens API',
@@ -249,6 +255,48 @@ const fr: Record<string, string> = {
'settings.mcp.toast.createError': 'Impossible de créer le token', 'settings.mcp.toast.createError': 'Impossible de créer le token',
'settings.mcp.toast.deleted': 'Token supprimé', 'settings.mcp.toast.deleted': 'Token supprimé',
'settings.mcp.toast.deleteError': 'Impossible de supprimer le token', 'settings.mcp.toast.deleteError': 'Impossible de supprimer le token',
'settings.mcp.apiTokensDeprecated': 'Les tokens API sont dépréciés et seront supprimés dans une prochaine version. Veuillez utiliser les clients OAuth 2.1 à la place.',
'settings.oauth.clients': 'Clients OAuth 2.1',
'settings.oauth.clientsHint': 'Enregistrez des clients OAuth 2.1 pour permettre à des applications MCP tierces (Claude Web, Cursor, etc.) de se connecter sans tokens statiques.',
'settings.oauth.createClient': 'Nouveau client',
'settings.oauth.noClients': 'Aucun client OAuth enregistré.',
'settings.oauth.clientId': 'ID client',
'settings.oauth.clientSecret': 'Secret client',
'settings.oauth.deleteClient': 'Supprimer le client',
'settings.oauth.deleteClientMessage': 'Ce client et toutes les sessions actives seront définitivement supprimés. Toute application l\'utilisant perdra immédiatement l\'accès.',
'settings.oauth.rotateSecret': 'Renouveler le secret',
'settings.oauth.rotateSecretMessage': 'Un nouveau secret client sera généré et toutes les sessions existantes seront immédiatement invalidées. Mettez à jour votre application avant de fermer cette fenêtre.',
'settings.oauth.rotateSecretConfirm': 'Renouveler',
'settings.oauth.rotateSecretConfirming': 'Renouvellement…',
'settings.oauth.rotateSecretDoneTitle': 'Nouveau secret généré',
'settings.oauth.rotateSecretDoneWarning': 'Ce secret n\'est affiché qu\'une seule fois. Copiez-le maintenant et mettez à jour votre application — toutes les sessions précédentes ont été invalidées.',
'settings.oauth.activeSessions': 'Sessions OAuth actives',
'settings.oauth.sessionScopes': 'Portées',
'settings.oauth.sessionExpires': 'Expire',
'settings.oauth.revoke': 'Révoquer',
'settings.oauth.revokeSession': 'Révoquer la session',
'settings.oauth.revokeSessionMessage': 'Cela révoquera immédiatement l\'accès pour cette session OAuth.',
'settings.oauth.modal.createTitle': 'Enregistrer un client OAuth',
'settings.oauth.modal.presets': 'Préréglages rapides',
'settings.oauth.modal.clientName': 'Nom de l\'application',
'settings.oauth.modal.clientNamePlaceholder': 'ex. Claude Web, Mon app MCP',
'settings.oauth.modal.redirectUris': 'URIs de redirection',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Une URI par ligne. HTTPS requis (localhost exempté). Correspondance exacte.',
'settings.oauth.modal.scopes': 'Portées autorisées',
'settings.oauth.modal.scopesHint': 'list_trips et get_trip_summary sont toujours disponibles — aucune portée requise. Ils permettent à l\'IA de découvrir les IDs de voyage nécessaires.',
'settings.oauth.modal.selectAll': 'Tout sélectionner',
'settings.oauth.modal.deselectAll': 'Tout désélectionner',
'settings.oauth.modal.creating': 'Enregistrement…',
'settings.oauth.modal.create': 'Enregistrer le client',
'settings.oauth.modal.createdTitle': 'Client enregistré',
'settings.oauth.modal.createdWarning': 'Le secret client n\'est affiché qu\'une seule fois. Copiez-le maintenant — il ne peut pas être récupéré.',
'settings.oauth.toast.createError': 'Impossible d\'enregistrer le client OAuth',
'settings.oauth.toast.deleted': 'Client OAuth supprimé',
'settings.oauth.toast.deleteError': 'Impossible de supprimer le client OAuth',
'settings.oauth.toast.revoked': 'Session révoquée',
'settings.oauth.toast.revokeError': 'Impossible de révoquer la session',
'settings.oauth.toast.rotateError': 'Impossible de renouveler le secret client',
'settings.account': 'Compte', 'settings.account': 'Compte',
'settings.about': 'À propos', 'settings.about': 'À propos',
'settings.about.reportBug': 'Signaler un bug', 'settings.about.reportBug': 'Signaler un bug',
@@ -364,6 +412,10 @@ const fr: Record<string, string> = {
'login.mfaHint': 'Ouvrez Google Authenticator, Authy ou une autre application TOTP.', 'login.mfaHint': 'Ouvrez Google Authenticator, Authy ou une autre application TOTP.',
'login.mfaBack': '← Retour à la connexion', 'login.mfaBack': '← Retour à la connexion',
'login.mfaVerify': 'Vérifier', 'login.mfaVerify': 'Vérifier',
'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré',
'login.oidcFailed': 'Échec de connexion OIDC',
'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire',
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
'login.oidc.tokenFailed': 'L\'authentification a échoué.', 'login.oidc.tokenFailed': 'L\'authentification a échoué.',
'login.oidc.invalidState': 'Session invalide. Veuillez réessayer.', 'login.oidc.invalidState': 'Session invalide. Veuillez réessayer.',
'login.demoFailed': 'Échec de la connexion démo', 'login.demoFailed': 'Échec de la connexion démo',
@@ -449,6 +501,17 @@ const fr: Record<string, string> = {
'admin.tabs.settings': 'Paramètres', 'admin.tabs.settings': 'Paramètres',
'admin.allowRegistration': 'Autoriser les inscriptions', 'admin.allowRegistration': 'Autoriser les inscriptions',
'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes', 'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Exiger l\'authentification à deux facteurs (2FA)', 'admin.requireMfa': 'Exiger l\'authentification à deux facteurs (2FA)',
'admin.requireMfaHint': 'Les utilisateurs sans 2FA doivent terminer la configuration dans Paramètres avant d\'utiliser l\'application.', 'admin.requireMfaHint': 'Les utilisateurs sans 2FA doivent terminer la configuration dans Paramètres avant d\'utiliser l\'application.',
'admin.apiKeys': 'Clés API', 'admin.apiKeys': 'Clés API',
@@ -560,9 +623,10 @@ const fr: Record<string, string> = {
'admin.audit.col.details': 'Détails', 'admin.audit.col.details': 'Détails',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'Tokens MCP', 'admin.tabs.mcpTokens': 'Accès MCP',
'admin.mcpTokens.title': 'Tokens MCP', 'admin.mcpTokens.title': 'Accès MCP',
'admin.mcpTokens.subtitle': 'Gérer les tokens API de tous les utilisateurs', 'admin.mcpTokens.subtitle': 'Gérer les sessions OAuth et les tokens API de tous les utilisateurs',
'admin.mcpTokens.sectionTitle': 'Tokens API',
'admin.mcpTokens.owner': 'Propriétaire', 'admin.mcpTokens.owner': 'Propriétaire',
'admin.mcpTokens.tokenName': 'Nom du token', 'admin.mcpTokens.tokenName': 'Nom du token',
'admin.mcpTokens.created': 'Créé', 'admin.mcpTokens.created': 'Créé',
@@ -574,6 +638,17 @@ const fr: Record<string, string> = {
'admin.mcpTokens.deleteSuccess': 'Token supprimé', 'admin.mcpTokens.deleteSuccess': 'Token supprimé',
'admin.mcpTokens.deleteError': 'Impossible de supprimer le token', 'admin.mcpTokens.deleteError': 'Impossible de supprimer le token',
'admin.mcpTokens.loadError': 'Impossible de charger les tokens', 'admin.mcpTokens.loadError': 'Impossible de charger les tokens',
'admin.oauthSessions.sectionTitle': 'Sessions OAuth',
'admin.oauthSessions.clientName': 'Client',
'admin.oauthSessions.owner': 'Propriétaire',
'admin.oauthSessions.scopes': 'Portées',
'admin.oauthSessions.created': 'Créé',
'admin.oauthSessions.empty': 'Aucune session OAuth active',
'admin.oauthSessions.revokeTitle': 'Révoquer la session',
'admin.oauthSessions.revokeMessage': 'Cette session OAuth sera révoquée immédiatement. Le client perdra l\'accès MCP.',
'admin.oauthSessions.revokeSuccess': 'Session révoquée',
'admin.oauthSessions.revokeError': 'Impossible de révoquer la session',
'admin.oauthSessions.loadError': 'Impossible de charger les sessions OAuth',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -656,6 +731,8 @@ const fr: Record<string, string> = {
'vacay.companyHolidays': 'Jours fériés d\'entreprise', 'vacay.companyHolidays': 'Jours fériés d\'entreprise',
'vacay.companyHolidaysHint': 'Autoriser le marquage des jours fériés d\'entreprise', 'vacay.companyHolidaysHint': 'Autoriser le marquage des jours fériés d\'entreprise',
'vacay.companyHolidaysNoDeduct': 'Les jours fériés d\'entreprise ne sont pas déduits des jours de vacances.', 'vacay.companyHolidaysNoDeduct': 'Les jours fériés d\'entreprise ne sont pas déduits des jours de vacances.',
'vacay.weekStart': 'La semaine commence le',
'vacay.weekStartHint': 'Choisissez si la semaine commence le lundi ou le dimanche',
'vacay.carryOver': 'Report', 'vacay.carryOver': 'Report',
'vacay.carryOverHint': 'Reporter automatiquement les jours de vacances restants à l\'année suivante', 'vacay.carryOverHint': 'Reporter automatiquement les jours de vacances restants à l\'année suivante',
'vacay.sharing': 'Partage', 'vacay.sharing': 'Partage',
@@ -870,6 +947,7 @@ const fr: Record<string, string> = {
'inspector.files': 'Fichiers', 'inspector.files': 'Fichiers',
'inspector.filesCount': '{count} fichiers', 'inspector.filesCount': '{count} fichiers',
'inspector.removeFromDay': 'Retirer du jour', 'inspector.removeFromDay': 'Retirer du jour',
'inspector.remove': 'Supprimer',
'inspector.addToDay': 'Ajouter au jour', 'inspector.addToDay': 'Ajouter au jour',
'inspector.confirmedRes': 'Réservation confirmée', 'inspector.confirmedRes': 'Réservation confirmée',
'inspector.pendingRes': 'Réservation en attente', 'inspector.pendingRes': 'Réservation en attente',
@@ -1010,6 +1088,7 @@ const fr: Record<string, string> = {
'budget.totalBudget': 'Budget total', 'budget.totalBudget': 'Budget total',
'budget.byCategory': 'Par catégorie', 'budget.byCategory': 'Par catégorie',
'budget.editTooltip': 'Cliquez pour modifier', 'budget.editTooltip': 'Cliquez pour modifier',
'budget.linkedToReservation': 'Lié à une réservation — modifiez le nom depuis celle-ci',
'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?', 'budget.confirm.deleteCategory': 'Voulez-vous vraiment supprimer la catégorie « {name} » avec {count} entrées ?',
'budget.deleteCategory': 'Supprimer la catégorie', 'budget.deleteCategory': 'Supprimer la catégorie',
'budget.perPerson': 'Par personne', 'budget.perPerson': 'Par personne',
@@ -1019,9 +1098,13 @@ const fr: Record<string, string> = {
'budget.settlement': 'Règlement', 'budget.settlement': 'Règlement',
'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.', 'budget.settlementInfo': 'Cliquez sur l\'avatar d\'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu\'il a payé. Le règlement indique ensuite qui doit combien à qui.',
'budget.netBalances': 'Soldes nets', 'budget.netBalances': 'Soldes nets',
'budget.linkedToReservation': 'Lié à une réservation — modifiez le nom là-bas',
// Files // Files
'files.title': 'Fichiers', 'files.title': 'Fichiers',
'files.pageTitle': 'Fichiers et documents',
'files.subtitle': '{count} fichiers pour {trip}',
'files.downloadPdf': 'Télécharger le PDF',
'files.count': '{count} fichiers', 'files.count': '{count} fichiers',
'files.countSingular': '1 fichier', 'files.countSingular': '1 fichier',
'files.uploaded': '{count} importés', 'files.uploaded': '{count} importés',
@@ -1108,7 +1191,9 @@ const fr: Record<string, string> = {
'packing.template': 'Modèle', 'packing.template': 'Modèle',
'packing.templateApplied': '{count} articles ajoutés depuis le modèle', 'packing.templateApplied': '{count} articles ajoutés depuis le modèle',
'packing.templateError': 'Erreur lors de l\'application du modèle', 'packing.templateError': 'Erreur lors de l\'application du modèle',
'packing.assignUser': 'Assigner un utilisateur', 'packing.saveAsTemplate': 'Enregistrer comme modèle',
'packing.templateName': 'Nom du modèle',
'packing.templateSaved': 'Liste de voyage enregistrée comme modèle',
'packing.noMembers': 'Aucun membre', 'packing.noMembers': 'Aucun membre',
'packing.bags': 'Bagages', 'packing.bags': 'Bagages',
'packing.noBag': 'Non assigné', 'packing.noBag': 'Non assigné',
@@ -1265,6 +1350,13 @@ const fr: Record<string, string> = {
'backup.keep.forever': 'Conserver indéfiniment', 'backup.keep.forever': 'Conserver indéfiniment',
// Photos // Photos
'photos.title': 'Photos',
'photos.subtitle': '{count} photos pour {trip}',
'photos.dropHere': 'Déposez des photos ici...',
'photos.dropHereActive': 'Déposez des photos ici',
'photos.captionForAll': 'Légende (pour tous)',
'photos.captionPlaceholder': 'Légende optionnelle...',
'photos.addCaption': 'Ajouter une légende...',
'photos.allDays': 'Tous les jours', 'photos.allDays': 'Tous les jours',
'photos.noPhotos': 'Aucune photo', 'photos.noPhotos': 'Aucune photo',
'photos.uploadHint': 'Importez vos photos de voyage', 'photos.uploadHint': 'Importez vos photos de voyage',
@@ -1272,6 +1364,12 @@ const fr: Record<string, string> = {
'photos.linkPlace': 'Lier au lieu', 'photos.linkPlace': 'Lier au lieu',
'photos.noPlace': 'Aucun lieu', 'photos.noPlace': 'Aucun lieu',
'photos.uploadN': '{n} photo(s) importée(s)', 'photos.uploadN': '{n} photo(s) importée(s)',
'photos.linkDay': 'Lier le jour',
'photos.noDay': 'Aucun jour',
'photos.dayLabel': 'Jour {number}',
'photos.photoSelected': 'Photo sélectionnée',
'photos.photosSelected': 'Photos sélectionnées',
'photos.fileTypeHint': "JPG, PNG, WebP · max. 10 Mo · jusqu'à 30 photos",
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?', 'backup.restoreConfirmTitle': 'Restaurer la sauvegarde ?',
@@ -1298,6 +1396,7 @@ const fr: Record<string, string> = {
'planner.routeCalculated': 'Itinéraire calculé', 'planner.routeCalculated': 'Itinéraire calculé',
'planner.routeCalcFailed': 'L\'itinéraire n\'a pas pu être calculé', 'planner.routeCalcFailed': 'L\'itinéraire n\'a pas pu être calculé',
'planner.routeError': 'Erreur lors du calcul de l\'itinéraire', 'planner.routeError': 'Erreur lors du calcul de l\'itinéraire',
'planner.icsExportFailed': 'Échec de l\'export ICS',
'planner.routeOptimized': 'Itinéraire optimisé', 'planner.routeOptimized': 'Itinéraire optimisé',
'planner.reservationUpdated': 'Réservation mise à jour', 'planner.reservationUpdated': 'Réservation mise à jour',
'planner.reservationAdded': 'Réservation ajoutée', 'planner.reservationAdded': 'Réservation ajoutée',
@@ -1383,6 +1482,7 @@ const fr: Record<string, string> = {
'memories.title': 'Photos', 'memories.title': 'Photos',
'memories.notConnected': 'Immich non connecté', 'memories.notConnected': 'Immich non connecté',
'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.', 'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.',
'memories.notConnectedMultipleHint': 'Connectez un de ces fournisseurs de photos : {provider_names} dans les Paramètres pour pouvoir ajouter des photos à ce voyage.',
'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.', 'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.',
'memories.noPhotos': 'Aucune photo trouvée', 'memories.noPhotos': 'Aucune photo trouvée',
'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.', 'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.',
@@ -1393,26 +1493,38 @@ const fr: Record<string, string> = {
'memories.reviewTitle': 'Vérifier vos photos', 'memories.reviewTitle': 'Vérifier vos photos',
'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.', 'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.',
'memories.shareCount': 'Partager {count} photos', 'memories.shareCount': 'Partager {count} photos',
'memories.immichUrl': 'URL du serveur Immich', 'memories.providerUrl': 'URL du serveur',
'memories.immichApiKey': 'Clé API', 'memories.providerApiKey': 'Clé API',
'memories.providerUsername': 'Nom d\'utilisateur',
'memories.providerPassword': 'Mot de passe',
'memories.providerOTP': 'Code MFA (si activé)',
'memories.skipSSLVerification': 'Ignorer la vérification du certificat SSL',
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo',
'memories.testConnection': 'Tester la connexion', 'memories.testConnection': 'Tester la connexion',
'memories.testFirst': 'Testez la connexion avant de sauvegarder', 'memories.testFirst': 'Testez la connexion avant de sauvegarder',
'memories.connected': 'Connecté', 'memories.connected': 'Connecté',
'memories.disconnected': 'Non connecté', 'memories.disconnected': 'Non connecté',
'memories.connectionSuccess': 'Connecté à Immich', 'memories.connectionSuccess': 'Connecté à Immich',
'memories.connectionError': 'Impossible de se connecter à Immich', 'memories.connectionError': 'Impossible de se connecter à Immich',
'memories.saved': 'Paramètres Immich enregistrés', 'memories.saved': 'Paramètres {provider_name} enregistrés',
'memories.providerDisconnectedBanner': 'Votre connexion {provider_name} est perdue. Reconnectez-vous dans les Paramètres pour voir les photos.',
'memories.saveError': 'Impossible d\'enregistrer les paramètres de {provider_name}',
'memories.saveRouteNotConfigured': "La route de sauvegarde n'est pas configurée pour ce fournisseur",
'memories.testRouteNotConfigured': "La route de test n'est pas configurée pour ce fournisseur",
'memories.fillRequiredFields': 'Veuillez remplir tous les champs obligatoires',
'memories.oldest': 'Plus anciennes', 'memories.oldest': 'Plus anciennes',
'memories.newest': 'Plus récentes', 'memories.newest': 'Plus récentes',
'memories.allLocations': 'Tous les lieux', 'memories.allLocations': 'Tous les lieux',
'memories.addPhotos': 'Ajouter des photos', 'memories.addPhotos': 'Ajouter des photos',
'memories.linkAlbum': 'Lier un album', 'memories.linkAlbum': 'Lier un album',
'memories.selectAlbum': 'Choisir un album Immich', 'memories.selectAlbum': 'Choisir un album Immich',
'memories.selectAlbumMultiple': 'Sélectionner un album',
'memories.noAlbums': 'Aucun album trouvé', 'memories.noAlbums': 'Aucun album trouvé',
'memories.syncAlbum': 'Synchroniser', 'memories.syncAlbum': 'Synchroniser',
'memories.unlinkAlbum': 'Délier', 'memories.unlinkAlbum': 'Délier',
'memories.photos': 'photos', 'memories.photos': 'photos',
'memories.selectPhotos': 'Sélectionner des photos depuis Immich', 'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
'memories.selectPhotosMultiple': 'Sélectionner des photos',
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.', 'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
'memories.selected': 'sélectionné(s)', 'memories.selected': 'sélectionné(s)',
'memories.addSelected': 'Ajouter {count} photos', 'memories.addSelected': 'Ajouter {count} photos',
@@ -1570,6 +1682,8 @@ const fr: Record<string, string> = {
'notifications.markUnread': 'Marquer comme non lu', 'notifications.markUnread': 'Marquer comme non lu',
'notifications.delete': 'Supprimer', 'notifications.delete': 'Supprimer',
'notifications.system': 'Système', 'notifications.system': 'Système',
'notifications.synologySessionCleared.title': 'Synology Photos déconnecté',
'notifications.synologySessionCleared.text': 'Votre serveur ou compte a changé — allez dans Paramètres pour tester à nouveau votre connexion.',
'memories.error.loadAlbums': 'Impossible de charger les albums', 'memories.error.loadAlbums': 'Impossible de charger les albums',
'memories.error.linkAlbum': 'Impossible de lier l\'album', 'memories.error.linkAlbum': 'Impossible de lier l\'album',
'memories.error.unlinkAlbum': 'Impossible de dissocier l\'album', 'memories.error.unlinkAlbum': 'Impossible de dissocier l\'album',
@@ -1692,6 +1806,306 @@ const fr: Record<string, string> = {
'notif.generic.text': 'Vous avez une nouvelle notification', 'notif.generic.text': 'Vous avez une nouvelle notification',
'notif.dev.unknown_event.title': '[DEV] Événement inconnu', 'notif.dev.unknown_event.title': '[DEV] Événement inconnu',
'notif.dev.unknown_event.text': 'Le type d\'événement "{event}" n\'est pas enregistré dans EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'Le type d\'événement "{event}" n\'est pas enregistré dans EVENT_NOTIFICATION_CONFIG',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'à l\'instant',
'common.hoursAgo': 'il y a {count}h',
'common.daysAgo': 'il y a {count}j',
'budget.linkedToReservation': 'Lié à une réservation — modifiez le nom là-bas',
'packing.saveAsTemplate': 'Enregistrer comme modèle',
'packing.templateName': 'Nom du modèle',
'packing.templateSaved': 'Liste de bagages enregistrée comme modèle',
'memories.notConnectedMultipleHint': 'Connectez l\'un de ces fournisseurs de photos : {provider_names} dans les Paramètres pour pouvoir ajouter des photos à ce voyage.',
'memories.providerUrl': 'URL du serveur',
'memories.providerApiKey': 'Clé API',
'memories.providerUsername': 'Nom d\'utilisateur',
'memories.providerPassword': 'Mot de passe',
'memories.saveError': 'Impossible d\'enregistrer les paramètres de {provider_name}',
'memories.selectAlbumMultiple': 'Sélectionner un album',
'memories.selectPhotosMultiple': 'Sélectionner des photos',
'journey.title': 'Journal de voyage',
'journey.subtitle': 'Suivez vos voyages en temps réel',
'journey.new': 'Nouveau journal',
'journey.create': 'Créer',
'journey.titlePlaceholder': 'Où allez-vous ?',
'journey.empty': 'Aucun journal pour le moment',
'journey.emptyHint': 'Commencez à documenter votre prochain voyage',
'journey.deleted': 'Journal supprimé',
'journey.createError': 'Impossible de créer le journal',
'journey.deleteError': 'Impossible de supprimer le journal',
'journey.deleteConfirmTitle': 'Supprimer',
'journey.deleteConfirmMessage': 'Supprimer « {title} » ? Cette action est irréversible.',
'journey.deleteConfirmGeneric': 'Êtes-vous sûr de vouloir supprimer ceci ?',
'journey.notFound': 'Journal introuvable',
'journey.photos': 'Photos',
'journey.timelineEmpty': 'Aucune étape pour le moment',
'journey.timelineEmptyHint': 'Ajoutez un check-in ou écrivez une entrée de journal pour commencer',
'journey.status.draft': 'Brouillon',
'journey.status.active': 'Actif',
'journey.status.completed': 'Terminé',
'journey.status.upcoming': 'À venir',
'journey.checkin.add': 'Check-in',
'journey.checkin.namePlaceholder': 'Nom du lieu',
'journey.checkin.notesPlaceholder': 'Notes (facultatif)',
'journey.checkin.save': 'Enregistrer',
'journey.checkin.error': 'Impossible d\'enregistrer le check-in',
'journey.entry.add': 'Journal',
'journey.entry.edit': 'Modifier l\'entrée',
'journey.entry.titlePlaceholder': 'Titre (facultatif)',
'journey.entry.bodyPlaceholder': 'Que s\'est-il passé aujourd\'hui ?',
'journey.entry.save': 'Enregistrer',
'journey.entry.error': 'Impossible d\'enregistrer l\'entrée',
'journey.photo.add': 'Photo',
'journey.photo.uploadError': 'Échec du téléversement',
'journey.share.share': 'Partager',
'journey.share.public': 'Public',
'journey.share.linkCopied': 'Lien public copié',
'journey.share.disabled': 'Partage public désactivé',
'journey.editor.titlePlaceholder': 'Donnez un nom à ce moment...',
'journey.editor.bodyPlaceholder': 'Racontez l\'histoire de cette journée...',
'journey.editor.placePlaceholder': 'Lieu (facultatif)',
'journey.editor.tagsPlaceholder': 'Tags : pépite cachée, meilleur repas, à revisiter...',
'journey.visibility.private': 'Privé',
'journey.visibility.shared': 'Partagé',
'journey.visibility.public': 'Public',
'journey.emptyState.title': 'Votre histoire commence ici',
'journey.emptyState.subtitle': 'Faites un check-in ou écrivez votre première entrée de journal',
'journey.frontpage.subtitle': 'Transformez vos voyages en histoires inoubliables',
'journey.frontpage.createJourney': 'Créer un journal',
'journey.frontpage.activeJourney': 'Journal actif',
'journey.frontpage.allJourneys': 'Tous les journaux',
'journey.frontpage.journeys': 'journaux',
'journey.frontpage.createNew': 'Créer un nouveau journal',
'journey.frontpage.createNewSub': 'Choisissez des voyages, écrivez des récits, partagez vos aventures',
'journey.frontpage.live': 'En direct',
'journey.frontpage.synced': 'Synchronisé',
'journey.frontpage.continueWriting': 'Continuer à écrire',
'journey.frontpage.updated': 'Mis à jour {time}',
'journey.frontpage.suggestionLabel': 'Voyage terminé récemment',
'journey.frontpage.suggestionText': 'Transformez <strong>{title}</strong> en journal de voyage',
'journey.frontpage.dismiss': 'Ignorer',
'journey.frontpage.journeyName': 'Nom du journal',
'journey.frontpage.namePlaceholder': 'ex. Asie du Sud-Est 2026',
'journey.frontpage.selectTrips': 'Sélectionner des voyages',
'journey.frontpage.tripsSelected': 'voyages sélectionnés',
'journey.frontpage.trips': 'voyages',
'journey.frontpage.placesImported': 'lieux seront importés',
'journey.frontpage.places': 'lieux',
'journey.detail.backToJourney': 'Retour au journal',
'journey.detail.syncedWithTrips': 'Synchronisé avec les voyages',
'journey.detail.addEntry': 'Ajouter une entrée',
'journey.detail.newEntry': 'Nouvelle entrée',
'journey.detail.editEntry': 'Modifier l\'entrée',
'journey.detail.noEntries': 'Aucune entrée pour le moment',
'journey.detail.noEntriesHint': 'Ajoutez un voyage pour commencer avec des entrées préremplies',
'journey.detail.noPhotos': 'Aucune photo pour le moment',
'journey.detail.noPhotosHint': 'Téléversez des photos dans les entrées ou parcourez votre bibliothèque Immich/Synology',
'journey.detail.journeyStats': 'Statistiques du journal',
'journey.detail.syncedTrips': 'Voyages synchronisés',
'journey.detail.noTripsLinked': 'Aucun voyage lié pour le moment',
'journey.detail.contributors': 'Contributeurs',
'journey.detail.readMore': 'Lire la suite',
'journey.detail.prosCons': 'Pour et contre',
'journey.stats.days': 'Jours',
'journey.stats.cities': 'Villes',
'journey.stats.entries': 'Entrées',
'journey.stats.photos': 'Photos',
'journey.stats.places': 'Lieux',
'journey.verdict.lovedIt': 'Adoré',
'journey.verdict.couldBeBetter': 'Pourrait être mieux',
'journey.synced.places': 'lieux',
'journey.synced.synced': 'synchronisé',
'journey.editor.uploadPhotos': 'Téléverser des photos',
'journey.editor.fromGallery': 'Depuis la galerie',
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
'journey.editor.writeStory': 'Écrivez votre histoire...',
'journey.editor.prosCons': 'Pour et contre',
'journey.editor.pros': 'Pour',
'journey.editor.cons': 'Contre',
'journey.editor.proPlaceholder': 'Quelque chose de génial...',
'journey.editor.conPlaceholder': 'Pas si génial...',
'journey.editor.addAnother': 'Ajouter un autre',
'journey.editor.date': 'Date',
'journey.editor.location': 'Lieu',
'journey.editor.searchLocation': 'Rechercher un lieu...',
'journey.editor.mood': 'Humeur',
'journey.editor.weather': 'Météo',
'journey.editor.photoFirst': '1er',
'journey.editor.makeFirst': 'Mettre en 1er',
'journey.mood.amazing': 'Incroyable',
'journey.mood.good': 'Bien',
'journey.mood.neutral': 'Neutre',
'journey.mood.rough': 'Difficile',
'journey.weather.sunny': 'Ensoleillé',
'journey.weather.partly': 'Partiellement nuageux',
'journey.weather.cloudy': 'Nuageux',
'journey.weather.rainy': 'Pluvieux',
'journey.weather.stormy': 'Orageux',
'journey.weather.cold': 'Neigeux',
'journey.trips.linkTrip': 'Lier un voyage',
'journey.trips.searchTrip': 'Rechercher un voyage',
'journey.trips.searchPlaceholder': 'Nom du voyage ou destination...',
'journey.trips.noTripsAvailable': 'Aucun voyage disponible',
'journey.trips.link': 'Lier',
'journey.trips.tripLinked': 'Voyage lié',
'journey.trips.linkFailed': 'Échec de la liaison du voyage',
'journey.trips.addTrip': 'Ajouter un voyage',
'journey.trips.unlinkTrip': 'Délier le voyage',
'journey.trips.unlinkMessage': 'Délier « {title} » ? Toutes les entrées et photos synchronisées de ce voyage seront définitivement supprimées. Cette action est irréversible.',
'journey.trips.unlink': 'Délier',
'journey.trips.tripUnlinked': 'Voyage délié',
'journey.trips.unlinkFailed': 'Échec de la suppression du lien',
'journey.trips.noTripsLinkedSettings': 'Aucun voyage lié',
'journey.contributors.invite': 'Inviter un contributeur',
'journey.contributors.searchUser': 'Rechercher un utilisateur',
'journey.contributors.searchPlaceholder': 'Nom d\'utilisateur ou e-mail...',
'journey.contributors.noUsers': 'Aucun utilisateur trouvé',
'journey.contributors.role': 'Rôle',
'journey.contributors.added': 'Contributeur ajouté',
'journey.contributors.addFailed': 'Échec de l\'ajout du contributeur',
'journey.share.publicShare': 'Partage public',
'journey.share.createLink': 'Créer un lien de partage',
'journey.share.linkCreated': 'Lien de partage créé',
'journey.share.createFailed': 'Échec de la création du lien',
'journey.share.copy': 'Copier',
'journey.share.copied': 'Copié !',
'journey.share.timeline': 'Chronologie',
'journey.share.gallery': 'Galerie',
'journey.share.map': 'Carte',
'journey.share.removeLink': 'Supprimer le lien de partage',
'journey.share.linkDeleted': 'Lien de partage supprimé',
'journey.share.deleteFailed': 'Échec de la suppression',
'journey.share.updateFailed': 'Échec de la mise à jour',
'journey.settings.title': 'Paramètres du journal',
'journey.settings.coverImage': 'Image de couverture',
'journey.settings.changeCover': 'Changer la couverture',
'journey.settings.addCover': 'Ajouter une image de couverture',
'journey.settings.name': 'Nom',
'journey.settings.subtitle': 'Sous-titre',
'journey.settings.subtitlePlaceholder': 'ex. Thaïlande, Vietnam et Cambodge',
'journey.settings.delete': 'Supprimer',
'journey.settings.deleteJourney': 'Supprimer le journal',
'journey.settings.deleteMessage': 'Supprimer « {title} » ? Toutes les entrées et photos seront perdues.',
'journey.settings.saved': 'Paramètres enregistrés',
'journey.settings.saveFailed': 'Échec de l\'enregistrement',
'journey.settings.coverUpdated': 'Couverture mise à jour',
'journey.settings.coverFailed': 'Échec du téléversement',
'journey.settings.failedToDelete': 'Échec de la suppression',
'journey.entries.deleteTitle': "Supprimer l'entrée",
'journey.photosUploaded': '{count} photos téléversées',
'journey.photosAdded': '{count} photos ajoutées',
'journey.public.notFound': 'Introuvable',
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
'journey.public.readOnly': 'Lecture seule · Journal public',
'journey.public.tagline': 'Travel Resource & Exploration Kit',
'journey.public.sharedVia': 'Partagé via',
'journey.public.madeWith': 'Créé avec',
'journey.pdf.journeyBook': 'Carnet de voyage',
'journey.pdf.madeWith': 'Créé avec TREK',
'journey.pdf.day': 'Jour',
'journey.pdf.theEnd': 'Fin',
'journey.pdf.saveAsPdf': 'Enregistrer en PDF',
'journey.pdf.pages': 'pages',
'dashboard.greeting.morning': 'Bonjour,',
'dashboard.greeting.afternoon': 'Bon après-midi,',
'dashboard.greeting.evening': 'Bonsoir,',
'dashboard.mobile.liveNow': 'En direct',
'dashboard.mobile.tripProgress': 'Progression du voyage',
'dashboard.mobile.daysLeft': '{count} jours restants',
'dashboard.mobile.places': 'Lieux',
'dashboard.mobile.buddies': 'Compagnons',
'dashboard.mobile.newTrip': 'Nouveau voyage',
'dashboard.mobile.currency': 'Devise',
'dashboard.mobile.timezone': 'Fuseau horaire',
'dashboard.mobile.upcomingTrips': 'Voyages à venir',
'dashboard.mobile.yourTrips': 'Vos voyages',
'dashboard.mobile.trips': 'voyages',
'dashboard.mobile.starts': 'Début',
'dashboard.mobile.duration': 'Durée',
'dashboard.mobile.day': 'jour',
'dashboard.mobile.days': 'jours',
'dashboard.mobile.ongoing': 'En cours',
'dashboard.mobile.startsToday': 'Commence aujourd\'hui',
'dashboard.mobile.tomorrow': 'Demain',
'dashboard.mobile.inDays': 'Dans {count} jours',
'dashboard.mobile.inMonths': 'Dans {count} mois',
'dashboard.mobile.completed': 'Terminé',
'dashboard.mobile.currencyConverter': 'Convertisseur de devises',
'nav.profile': 'Profil',
'nav.bottomSettings': 'Paramètres',
'nav.bottomAdmin': 'Administration',
'nav.bottomLogout': 'Déconnexion',
'nav.bottomAdminBadge': 'Admin',
'dayplan.mobile.addPlace': 'Ajouter un lieu',
'dayplan.mobile.searchPlaces': 'Rechercher des lieux...',
'dayplan.mobile.allAssigned': 'Tous les lieux attribués',
'dayplan.mobile.noMatch': 'Aucun résultat',
'dayplan.mobile.createNew': 'Créer un nouveau lieu',
'admin.addons.catalog.journey.name': 'Journal de voyage',
'admin.addons.catalog.journey.description': 'Suivi de voyages et journal avec check-ins, photos et récits quotidiens',
// OAuth scope groups
'oauth.scope.group.trips': 'Voyages',
'oauth.scope.group.places': 'Lieux',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Bagages',
'oauth.scope.group.todos': 'Tâches',
'oauth.scope.group.budget': 'Budget',
'oauth.scope.group.reservations': 'Réservations',
'oauth.scope.group.collab': 'Collaboration',
'oauth.scope.group.notifications': 'Notifications',
'oauth.scope.group.vacay': 'Congés',
'oauth.scope.group.geo': 'Géo',
'oauth.scope.group.weather': 'Météo',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Voir les voyages et itinéraires',
'oauth.scope.trips:read.description': 'Lire les voyages, jours, notes et membres',
'oauth.scope.trips:write.label': 'Modifier les voyages et itinéraires',
'oauth.scope.trips:write.description': 'Créer et mettre à jour les voyages, jours, notes et gérer les membres',
'oauth.scope.trips:delete.label': 'Supprimer des voyages',
'oauth.scope.trips:delete.description': 'Supprimer définitivement des voyages entiers — cette action est irréversible',
'oauth.scope.trips:share.label': 'Gérer les liens de partage',
'oauth.scope.trips:share.description': 'Créer, modifier et révoquer des liens de partage publics',
'oauth.scope.places:read.label': 'Voir les lieux et données cartographiques',
'oauth.scope.places:read.description': 'Lire les lieux, affectations de jours, étiquettes et catégories',
'oauth.scope.places:write.label': 'Gérer les lieux',
'oauth.scope.places:write.description': 'Créer, modifier et supprimer des lieux, affectations et étiquettes',
'oauth.scope.atlas:read.label': 'Voir l\'Atlas',
'oauth.scope.atlas:read.description': 'Lire les pays visités, régions et liste de souhaits',
'oauth.scope.atlas:write.label': 'Gérer l\'Atlas',
'oauth.scope.atlas:write.description': 'Marquer des pays et régions visités, gérer la liste de souhaits',
'oauth.scope.packing:read.label': 'Voir les listes de bagages',
'oauth.scope.packing:read.description': 'Lire les articles, sacs et assignations de catégories',
'oauth.scope.packing:write.label': 'Gérer les listes de bagages',
'oauth.scope.packing:write.description': 'Ajouter, modifier, supprimer, cocher et réordonner les articles et sacs',
'oauth.scope.todos:read.label': 'Voir les listes de tâches',
'oauth.scope.todos:read.description': 'Lire les tâches et assignations de catégories',
'oauth.scope.todos:write.label': 'Gérer les listes de tâches',
'oauth.scope.todos:write.description': 'Créer, modifier, cocher, supprimer et réordonner les tâches',
'oauth.scope.budget:read.label': 'Voir le budget',
'oauth.scope.budget:read.description': 'Lire les dépenses et la répartition du budget',
'oauth.scope.budget:write.label': 'Gérer le budget',
'oauth.scope.budget:write.description': 'Créer, modifier et supprimer des dépenses',
'oauth.scope.reservations:read.label': 'Voir les réservations',
'oauth.scope.reservations:read.description': 'Lire les réservations et détails d\'hébergement',
'oauth.scope.reservations:write.label': 'Gérer les réservations',
'oauth.scope.reservations:write.description': 'Créer, modifier, supprimer et réordonner les réservations',
'oauth.scope.collab:read.label': 'Voir la collaboration',
'oauth.scope.collab:read.description': 'Lire les notes, sondages et messages collaboratifs',
'oauth.scope.collab:write.label': 'Gérer la collaboration',
'oauth.scope.collab:write.description': 'Créer, modifier et supprimer des notes, sondages et messages',
'oauth.scope.notifications:read.label': 'Voir les notifications',
'oauth.scope.notifications:read.description': 'Lire les notifications et le nombre de non-lus',
'oauth.scope.notifications:write.label': 'Gérer les notifications',
'oauth.scope.notifications:write.description': 'Marquer les notifications comme lues et y répondre',
'oauth.scope.vacay:read.label': 'Voir les plans de congés',
'oauth.scope.vacay:read.description': 'Lire les données, entrées et statistiques de congés',
'oauth.scope.vacay:write.label': 'Gérer les plans de congés',
'oauth.scope.vacay:write.description': 'Créer et gérer les entrées de congés, jours fériés et plans d\'équipe',
'oauth.scope.geo:read.label': 'Cartes et géocodage',
'oauth.scope.geo:read.description': 'Chercher des lieux, résoudre des URL cartographiques et géocoder des coordonnées',
'oauth.scope.weather:read.label': 'Prévisions météo',
'oauth.scope.weather:read.description': 'Obtenir les prévisions météo pour les lieux et dates de voyage',
} }
export default fr export default fr
+424 -10
View File
@@ -8,6 +8,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.loading': 'Betöltés...', 'common.loading': 'Betöltés...',
'common.import': 'Importálás', 'common.import': 'Importálás',
'common.error': 'Hiba', 'common.error': 'Hiba',
'common.unknownError': 'Ismeretlen hiba',
'common.tooManyAttempts': 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
'common.back': 'Vissza', 'common.back': 'Vissza',
'common.all': 'Összes', 'common.all': 'Összes',
'common.close': 'Bezárás', 'common.close': 'Bezárás',
@@ -26,6 +28,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Jelszó', 'common.password': 'Jelszó',
'common.saving': 'Mentés...', 'common.saving': 'Mentés...',
'trips.memberRemoved': '{username} eltávolítva',
'trips.memberRemoveError': 'Eltávolítás sikertelen',
'trips.memberAdded': '{username} hozzáadva',
'trips.memberAddError': 'Hozzáadás sikertelen',
'common.expand': 'Kibontás',
'common.collapse': 'Összecsukás',
'common.saved': 'Mentve', 'common.saved': 'Mentve',
'trips.reminder': 'Emlékeztető', 'trips.reminder': 'Emlékeztető',
'trips.reminderNone': 'Nincs', 'trips.reminderNone': 'Nincs',
@@ -180,6 +188,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'MCP végpont', 'settings.mcp.endpoint': 'MCP végpont',
'settings.mcp.clientConfig': 'Kliens konfiguráció', 'settings.mcp.clientConfig': 'Kliens konfiguráció',
'settings.mcp.clientConfigHint': 'Cserélje ki a <your_token> részt egy API tokenre az alábbi listából. Az npx elérési útját szükség lehet módosítani a rendszeréhez (pl. C:\\PROGRA~1\\nodejs\\npx.cmd Windows-on).', 'settings.mcp.clientConfigHint': 'Cserélje ki a <your_token> részt egy API tokenre az alábbi listából. Az npx elérési útját szükség lehet módosítani a rendszeréhez (pl. C:\\PROGRA~1\\nodejs\\npx.cmd Windows-on).',
'settings.mcp.clientConfigHintOAuth': 'Cserélje ki a <your_client_id> és <your_client_secret> részeket a fent létrehozott OAuth 2.1 kliens adataival. Az mcp-remote megnyitja a böngészőt az első csatlakozáskor az engedélyezés elvégzéséhez. Az npx elérési útját szükség lehet módosítani a rendszeréhez (pl. C:\\PROGRA~1\\nodejs\\npx.cmd Windows-on).',
'settings.mcp.copy': 'Másolás', 'settings.mcp.copy': 'Másolás',
'settings.mcp.copied': 'Másolva!', 'settings.mcp.copied': 'Másolva!',
'settings.mcp.apiTokens': 'API tokenek', 'settings.mcp.apiTokens': 'API tokenek',
@@ -201,6 +210,48 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'Nem sikerült létrehozni a tokent', 'settings.mcp.toast.createError': 'Nem sikerült létrehozni a tokent',
'settings.mcp.toast.deleted': 'Token törölve', 'settings.mcp.toast.deleted': 'Token törölve',
'settings.mcp.toast.deleteError': 'Nem sikerült törölni a tokent', 'settings.mcp.toast.deleteError': 'Nem sikerült törölni a tokent',
'settings.mcp.apiTokensDeprecated': 'Az API tokenek elavultak és egy jövőbeli verzióban eltávolításra kerülnek. Kérjük, használjon helyettük OAuth 2.1 klienseket.',
'settings.oauth.clients': 'OAuth 2.1 kliensek',
'settings.oauth.clientsHint': 'Regisztráljon OAuth 2.1 klienseket, hogy a harmadik féltől származó MCP alkalmazások (Claude Web, Cursor stb.) statikus tokenek nélkül csatlakozhassanak.',
'settings.oauth.createClient': 'Új kliens',
'settings.oauth.noClients': 'Nincs regisztrált OAuth kliens.',
'settings.oauth.clientId': 'Kliens azonosító',
'settings.oauth.clientSecret': 'Kliens titok',
'settings.oauth.deleteClient': 'Kliens törlése',
'settings.oauth.deleteClientMessage': 'Ez a kliens és az összes aktív munkamenet véglegesen törlésre kerül. Minden alkalmazás, amely ezt használja, azonnal elveszíti a hozzáférést.',
'settings.oauth.rotateSecret': 'Titok megújítása',
'settings.oauth.rotateSecretMessage': 'Új kliens titok kerül generálásra és az összes meglévő munkamenet azonnal érvénytelenné válik. Frissítse alkalmazását a párbeszéd bezárása előtt.',
'settings.oauth.rotateSecretConfirm': 'Megújítás',
'settings.oauth.rotateSecretConfirming': 'Megújítás…',
'settings.oauth.rotateSecretDoneTitle': 'Új titok generálva',
'settings.oauth.rotateSecretDoneWarning': 'Ez a titok csak egyszer jelenik meg. Másolja most és frissítse alkalmazását — az összes korábbi munkamenet érvénytelenné vált.',
'settings.oauth.activeSessions': 'Aktív OAuth munkamenetek',
'settings.oauth.sessionScopes': 'Jogosultságok',
'settings.oauth.sessionExpires': 'Lejár',
'settings.oauth.revoke': 'Visszavonás',
'settings.oauth.revokeSession': 'Munkamenet visszavonása',
'settings.oauth.revokeSessionMessage': 'Ez azonnal visszavonja a hozzáférést ehhez az OAuth munkamenethez.',
'settings.oauth.modal.createTitle': 'OAuth kliens regisztrálása',
'settings.oauth.modal.presets': 'Gyors beállítások',
'settings.oauth.modal.clientName': 'Alkalmazás neve',
'settings.oauth.modal.clientNamePlaceholder': 'pl. Claude Web, Az én MCP appom',
'settings.oauth.modal.redirectUris': 'Átirányítási URI-k',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Soronként egy URI. HTTPS szükséges (localhost kivételével). Pontos egyezés szükséges.',
'settings.oauth.modal.scopes': 'Engedélyezett jogosultságok',
'settings.oauth.modal.scopesHint': 'A list_trips és get_trip_summary mindig elérhető — jogosultság nélkül. Segítenek az AI-nak megtalálni az utazás azonosítókat.',
'settings.oauth.modal.selectAll': 'Összes kijelölése',
'settings.oauth.modal.deselectAll': 'Összes kijelölés törlése',
'settings.oauth.modal.creating': 'Regisztrálás…',
'settings.oauth.modal.create': 'Kliens regisztrálása',
'settings.oauth.modal.createdTitle': 'Kliens regisztrálva',
'settings.oauth.modal.createdWarning': 'A kliens titok csak egyszer jelenik meg. Másolja most — nem állítható helyre.',
'settings.oauth.toast.createError': 'Az OAuth kliens regisztrálása sikertelen',
'settings.oauth.toast.deleted': 'OAuth kliens törölve',
'settings.oauth.toast.deleteError': 'Az OAuth kliens törlése sikertelen',
'settings.oauth.toast.revoked': 'Munkamenet visszavonva',
'settings.oauth.toast.revokeError': 'A munkamenet visszavonása sikertelen',
'settings.oauth.toast.rotateError': 'A kliens titok megújítása sikertelen',
'settings.account': 'Fiók', 'settings.account': 'Fiók',
'settings.about': 'Névjegy', 'settings.about': 'Névjegy',
'settings.about.reportBug': 'Hiba bejelentése', 'settings.about.reportBug': 'Hiba bejelentése',
@@ -274,9 +325,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.none': 'Kikapcsolva', 'admin.notifications.none': 'Kikapcsolva',
'admin.notifications.email': 'E-mail (SMTP)', 'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Értesítési események',
'admin.notifications.eventsHint': 'Válaszd ki, mely események indítsanak értesítéseket minden felhasználó számára.',
'admin.notifications.configureFirst': 'Először konfiguráld az SMTP vagy webhook beállításokat lent, majd engedélyezd az eseményeket.',
'admin.notifications.save': 'Értesítési beállítások mentése', 'admin.notifications.save': 'Értesítési beállítások mentése',
'admin.notifications.saved': 'Értesítési beállítások mentve', 'admin.notifications.saved': 'Értesítési beállítások mentve',
'admin.notifications.testWebhook': 'Teszt webhook küldése', 'admin.notifications.testWebhook': 'Teszt webhook küldése',
@@ -371,6 +419,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.mfaHint': 'Nyisd meg a Google Authenticator, Authy vagy más TOTP alkalmazást.', 'login.mfaHint': 'Nyisd meg a Google Authenticator, Authy vagy más TOTP alkalmazást.',
'login.mfaBack': '← Vissza a bejelentkezéshez', 'login.mfaBack': '← Vissza a bejelentkezéshez',
'login.mfaVerify': 'Ellenőrzés', 'login.mfaVerify': 'Ellenőrzés',
'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink',
'login.oidcFailed': 'OIDC bejelentkezés sikertelen',
'login.usernameRequired': 'A felhasználónév kötelező',
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
// Regisztráció // Regisztráció
'register.passwordMismatch': 'A jelszavak nem egyeznek', 'register.passwordMismatch': 'A jelszavak nem egyeznek',
@@ -449,6 +501,17 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Beállítások', 'admin.tabs.settings': 'Beállítások',
'admin.allowRegistration': 'Regisztráció engedélyezése', 'admin.allowRegistration': 'Regisztráció engedélyezése',
'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat', 'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Kétlépcsős hitelesítés (2FA) kötelezővé tétele', 'admin.requireMfa': 'Kétlépcsős hitelesítés (2FA) kötelezővé tétele',
'admin.requireMfaHint': 'A 2FA nélküli felhasználóknak a Beállításokban kell befejezniük a beállítást az alkalmazás használata előtt.', 'admin.requireMfaHint': 'A 2FA nélküli felhasználóknak a Beállításokban kell befejezniük a beállítást az alkalmazás használata előtt.',
'admin.apiKeys': 'API kulcsok', 'admin.apiKeys': 'API kulcsok',
@@ -561,9 +624,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.audit.col.details': 'Részletek', 'admin.audit.col.details': 'Részletek',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'MCP tokenek', 'admin.tabs.mcpTokens': 'MCP hozzáférés',
'admin.mcpTokens.title': 'MCP tokenek', 'admin.mcpTokens.title': 'MCP hozzáférés',
'admin.mcpTokens.subtitle': 'Összes felhasználó API tokeneinek kezelése', 'admin.mcpTokens.subtitle': 'OAuth munkamenetek és API tokenek kezelése az összes felhasználó számára',
'admin.mcpTokens.sectionTitle': 'API tokenek',
'admin.mcpTokens.owner': 'Tulajdonos', 'admin.mcpTokens.owner': 'Tulajdonos',
'admin.mcpTokens.tokenName': 'Token neve', 'admin.mcpTokens.tokenName': 'Token neve',
'admin.mcpTokens.created': 'Létrehozva', 'admin.mcpTokens.created': 'Létrehozva',
@@ -575,6 +639,17 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.mcpTokens.deleteSuccess': 'Token törölve', 'admin.mcpTokens.deleteSuccess': 'Token törölve',
'admin.mcpTokens.deleteError': 'Nem sikerült törölni a tokent', 'admin.mcpTokens.deleteError': 'Nem sikerült törölni a tokent',
'admin.mcpTokens.loadError': 'Nem sikerült betölteni a tokeneket', 'admin.mcpTokens.loadError': 'Nem sikerült betölteni a tokeneket',
'admin.oauthSessions.sectionTitle': 'OAuth munkamenetek',
'admin.oauthSessions.clientName': 'Kliens',
'admin.oauthSessions.owner': 'Tulajdonos',
'admin.oauthSessions.scopes': 'Jogosultságok',
'admin.oauthSessions.created': 'Létrehozva',
'admin.oauthSessions.empty': 'Nincsenek aktív OAuth munkamenetek',
'admin.oauthSessions.revokeTitle': 'Munkamenet visszavonása',
'admin.oauthSessions.revokeMessage': 'Ez az OAuth munkamenet azonnal visszavonásra kerül. A kliens elveszíti az MCP hozzáférést.',
'admin.oauthSessions.revokeSuccess': 'Munkamenet visszavonva',
'admin.oauthSessions.revokeError': 'Nem sikerült visszavonni a munkamenetet',
'admin.oauthSessions.loadError': 'Nem sikerült betölteni az OAuth munkameneteket',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -661,6 +736,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'vacay.companyHolidays': 'Céges szabadnapok', 'vacay.companyHolidays': 'Céges szabadnapok',
'vacay.companyHolidaysHint': 'Céges szintű szabadnapok megjelölésének engedélyezése', 'vacay.companyHolidaysHint': 'Céges szintű szabadnapok megjelölésének engedélyezése',
'vacay.companyHolidaysNoDeduct': 'A céges szabadnapok nem számítanak bele a szabadságkeretbe.', 'vacay.companyHolidaysNoDeduct': 'A céges szabadnapok nem számítanak bele a szabadságkeretbe.',
'vacay.weekStart': 'A hét kezdőnapja',
'vacay.weekStartHint': 'Válaszd ki, hogy a hét hétfőn vagy vasárnap kezdődjön',
'vacay.carryOver': 'Szabadság átvitele', 'vacay.carryOver': 'Szabadság átvitele',
'vacay.carryOverHint': 'Megmaradt szabadságnapok automatikus átvitele a következő évre', 'vacay.carryOverHint': 'Megmaradt szabadságnapok automatikus átvitele a következő évre',
'vacay.sharing': 'Megosztás', 'vacay.sharing': 'Megosztás',
@@ -871,6 +948,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'inspector.files': 'Fájlok', 'inspector.files': 'Fájlok',
'inspector.filesCount': '{count} fájl', 'inspector.filesCount': '{count} fájl',
'inspector.removeFromDay': 'Eltávolítás a napról', 'inspector.removeFromDay': 'Eltávolítás a napról',
'inspector.remove': 'Eltávolítás',
'inspector.addToDay': 'Hozzáadás a naphoz', 'inspector.addToDay': 'Hozzáadás a naphoz',
'inspector.confirmedRes': 'Megerősített foglalás', 'inspector.confirmedRes': 'Megerősített foglalás',
'inspector.pendingRes': 'Függőben lévő foglalás', 'inspector.pendingRes': 'Függőben lévő foglalás',
@@ -1011,6 +1089,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'budget.totalBudget': 'Teljes költségvetés', 'budget.totalBudget': 'Teljes költségvetés',
'budget.byCategory': 'Kategóriánként', 'budget.byCategory': 'Kategóriánként',
'budget.editTooltip': 'Kattints a szerkesztéshez', 'budget.editTooltip': 'Kattints a szerkesztéshez',
'budget.linkedToReservation': 'Foglaláshoz kapcsolva — ott szerkessze a nevet',
'budget.confirm.deleteCategory': 'Biztosan törölni szeretnéd a(z) "{name}" kategóriát {count} bejegyzéssel?', 'budget.confirm.deleteCategory': 'Biztosan törölni szeretnéd a(z) "{name}" kategóriát {count} bejegyzéssel?',
'budget.deleteCategory': 'Kategória törlése', 'budget.deleteCategory': 'Kategória törlése',
'budget.perPerson': 'Személyenként', 'budget.perPerson': 'Személyenként',
@@ -1020,9 +1099,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Elszámolás', 'budget.settlement': 'Elszámolás',
'budget.settlementInfo': 'Kattints egy tag avatárjára egy költségvetési tételen a zöld jelöléshez — ez azt jelenti, hogy fizetett. Az elszámolás ezután mutatja, ki kinek mennyivel tartozik.', 'budget.settlementInfo': 'Kattints egy tag avatárjára egy költségvetési tételen a zöld jelöléshez — ez azt jelenti, hogy fizetett. Az elszámolás ezután mutatja, ki kinek mennyivel tartozik.',
'budget.netBalances': 'Nettó egyenlegek', 'budget.netBalances': 'Nettó egyenlegek',
'budget.linkedToReservation': 'Foglaláshoz kapcsolva — ott módosítsa a nevet',
// Fájlok // Fájlok
'files.title': 'Fájlok', 'files.title': 'Fájlok',
'files.pageTitle': 'Fájlok és dokumentumok',
'files.subtitle': '{count} fájl a következőhöz: {trip}',
'files.downloadPdf': 'PDF letöltése',
'files.count': '{count} fájl', 'files.count': '{count} fájl',
'files.countSingular': '1 fájl', 'files.countSingular': '1 fájl',
'files.uploaded': '{count} feltöltve', 'files.uploaded': '{count} feltöltve',
@@ -1101,7 +1184,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'packing.menuCheckAll': 'Összes kipipálása', 'packing.menuCheckAll': 'Összes kipipálása',
'packing.menuUncheckAll': 'Összes jelölés törlése', 'packing.menuUncheckAll': 'Összes jelölés törlése',
'packing.menuDeleteCat': 'Kategória törlése', 'packing.menuDeleteCat': 'Kategória törlése',
'packing.assignUser': 'Felhasználó hozzárendelése',
'packing.noMembers': 'Nincsenek utazási tagok', 'packing.noMembers': 'Nincsenek utazási tagok',
'packing.addItem': 'Tétel hozzáadása', 'packing.addItem': 'Tétel hozzáadása',
'packing.addItemPlaceholder': 'Tétel neve...', 'packing.addItemPlaceholder': 'Tétel neve...',
@@ -1111,6 +1193,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'packing.template': 'Sablon', 'packing.template': 'Sablon',
'packing.templateApplied': '{count} tétel hozzáadva a sablonból', 'packing.templateApplied': '{count} tétel hozzáadva a sablonból',
'packing.templateError': 'Nem sikerült alkalmazni a sablont', 'packing.templateError': 'Nem sikerült alkalmazni a sablont',
'packing.saveAsTemplate': 'Mentés sablonként',
'packing.templateName': 'Sablon neve',
'packing.templateSaved': 'Csomaglista elmentve sablonként',
'packing.bags': 'Táskák', 'packing.bags': 'Táskák',
'packing.noBag': 'Nincs hozzárendelve', 'packing.noBag': 'Nincs hozzárendelve',
'packing.totalWeight': 'Összsúly', 'packing.totalWeight': 'Összsúly',
@@ -1266,6 +1351,13 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'backup.keep.forever': 'Örökre megőrzés', 'backup.keep.forever': 'Örökre megőrzés',
// Fotók // Fotók
'photos.title': 'Fotók',
'photos.subtitle': '{count} fotó a következőhöz: {trip}',
'photos.dropHere': 'Húzza ide a fényképeket...',
'photos.dropHereActive': 'Húzza ide a fényképeket',
'photos.captionForAll': 'Felirat (mindenkinek)',
'photos.captionPlaceholder': 'Opcionális felirat...',
'photos.addCaption': 'Felirat hozzáadása...',
'photos.allDays': 'Minden nap', 'photos.allDays': 'Minden nap',
'photos.noPhotos': 'Még nincsenek fotók', 'photos.noPhotos': 'Még nincsenek fotók',
'photos.uploadHint': 'Töltsd fel az úti fotóidat', 'photos.uploadHint': 'Töltsd fel az úti fotóidat',
@@ -1273,6 +1365,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'photos.linkPlace': 'Hely társítása', 'photos.linkPlace': 'Hely társítása',
'photos.noPlace': 'Nincs hely', 'photos.noPlace': 'Nincs hely',
'photos.uploadN': '{n} fotó feltöltése', 'photos.uploadN': '{n} fotó feltöltése',
'photos.linkDay': 'Nap csatolása',
'photos.noDay': 'Nincs nap',
'photos.dayLabel': '{number}. nap',
'photos.photoSelected': 'Fotó kiválasztva',
'photos.photosSelected': 'Fotók kiválasztva',
'photos.fileTypeHint': 'JPG, PNG, WebP · max. 10 MB · legfeljebb 30 fotó',
// Mentés visszaállítása modal // Mentés visszaállítása modal
'backup.restoreConfirmTitle': 'Mentés visszaállítása?', 'backup.restoreConfirmTitle': 'Mentés visszaállítása?',
@@ -1299,6 +1397,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'planner.routeCalculated': 'Útvonal kiszámítva', 'planner.routeCalculated': 'Útvonal kiszámítva',
'planner.routeCalcFailed': 'Nem sikerült kiszámítani az útvonalat', 'planner.routeCalcFailed': 'Nem sikerült kiszámítani az útvonalat',
'planner.routeError': 'Hiba az útvonalszámítás során', 'planner.routeError': 'Hiba az útvonalszámítás során',
'planner.icsExportFailed': 'Az ICS-exportálás sikertelen',
'planner.routeOptimized': 'Útvonal optimalizálva', 'planner.routeOptimized': 'Útvonal optimalizálva',
'planner.reservationUpdated': 'Foglalás frissítve', 'planner.reservationUpdated': 'Foglalás frissítve',
'planner.reservationAdded': 'Foglalás hozzáadva', 'planner.reservationAdded': 'Foglalás hozzáadva',
@@ -1454,6 +1553,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Fotók', 'memories.title': 'Fotók',
'memories.notConnected': 'Immich nincs csatlakoztatva', 'memories.notConnected': 'Immich nincs csatlakoztatva',
'memories.notConnectedHint': 'Csatlakoztasd az Immich példányodat a Beállításokban, hogy itt lásd az utazási fotóidat.', 'memories.notConnectedHint': 'Csatlakoztasd az Immich példányodat a Beállításokban, hogy itt lásd az utazási fotóidat.',
'memories.notConnectedMultipleHint': 'A fényképek hozzáadásához csatlakoztasson egyet a következő fényképszolgáltatók közül a Beállításokban: {provider_names}.',
'memories.noDates': 'Adj hozzá dátumokat az utazáshoz a fotók betöltéséhez.', 'memories.noDates': 'Adj hozzá dátumokat az utazáshoz a fotók betöltéséhez.',
'memories.noPhotos': 'Nem találhatók fotók', 'memories.noPhotos': 'Nem találhatók fotók',
'memories.noPhotosHint': 'Nem találhatók fotók az Immichben erre az utazási időszakra.', 'memories.noPhotosHint': 'Nem találhatók fotók az Immichben erre az utazási időszakra.',
@@ -1464,23 +1564,32 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Nézd át a fotóidat', 'memories.reviewTitle': 'Nézd át a fotóidat',
'memories.reviewHint': 'Kattints a fotókra a megosztásból való kizáráshoz.', 'memories.reviewHint': 'Kattints a fotókra a megosztásból való kizáráshoz.',
'memories.shareCount': '{count} fotó megosztása', 'memories.shareCount': '{count} fotó megosztása',
'memories.immichUrl': 'Immich szerver URL', 'memories.providerUrl': 'Szerver URL',
'memories.immichApiKey': 'API kulcs', 'memories.providerApiKey': 'API kulcs',
'memories.providerUsername': 'Felhasználónév',
'memories.providerPassword': 'Jelszó',
'memories.providerOTP': 'MFA kód (ha engedélyezve van)',
'memories.skipSSLVerification': 'SSL tanúsítvány ellenőrzésének kihagyása',
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo',
'memories.testConnection': 'Kapcsolat tesztelése', 'memories.testConnection': 'Kapcsolat tesztelése',
'memories.testFirst': 'Először teszteld a kapcsolatot', 'memories.testFirst': 'Először teszteld a kapcsolatot',
'memories.connected': 'Csatlakoztatva', 'memories.connected': 'Csatlakoztatva',
'memories.disconnected': 'Nincs csatlakoztatva', 'memories.disconnected': 'Nincs csatlakoztatva',
'memories.connectionSuccess': 'Csatlakozva az Immichhez', 'memories.connectionSuccess': 'Csatlakozva az Immichhez',
'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez', 'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez',
'memories.saved': 'Immich beállítások mentve', 'memories.saved': '{provider_name} beállítások mentve',
'memories.providerDisconnectedBanner': 'A {provider_name} kapcsolat megszakadt. Csatlakozzon újra a Beállításokban a fényképek megtekintéséhez.',
'memories.saveError': 'Nem sikerült menteni a(z) {provider_name} beállításait',
'memories.addPhotos': 'Fotók hozzáadása', 'memories.addPhotos': 'Fotók hozzáadása',
'memories.linkAlbum': 'Album csatolása', 'memories.linkAlbum': 'Album csatolása',
'memories.selectAlbum': 'Immich album kiválasztása', 'memories.selectAlbum': 'Immich album kiválasztása',
'memories.selectAlbumMultiple': 'Album kiválasztása',
'memories.noAlbums': 'Nem található album', 'memories.noAlbums': 'Nem található album',
'memories.syncAlbum': 'Album szinkronizálása', 'memories.syncAlbum': 'Album szinkronizálása',
'memories.unlinkAlbum': 'Leválasztás', 'memories.unlinkAlbum': 'Leválasztás',
'memories.photos': 'fotó', 'memories.photos': 'fotó',
'memories.selectPhotos': 'Fotók kiválasztása az Immichből', 'memories.selectPhotos': 'Fotók kiválasztása az Immichből',
'memories.selectPhotosMultiple': 'Fényképek kiválasztása',
'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.', 'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
'memories.selected': 'kijelölve', 'memories.selected': 'kijelölve',
'memories.addSelected': '{count} fotó hozzáadása', 'memories.addSelected': '{count} fotó hozzáadása',
@@ -1571,6 +1680,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Olvasatlannak jelölés', 'notifications.markUnread': 'Olvasatlannak jelölés',
'notifications.delete': 'Törlés', 'notifications.delete': 'Törlés',
'notifications.system': 'Rendszer', 'notifications.system': 'Rendszer',
'notifications.synologySessionCleared.title': 'Synology Photos leválasztva',
'notifications.synologySessionCleared.text': 'A szerver vagy a fiók megváltozott — lépjen a Beállításokba a kapcsolat újrateszteléséhez.',
'memories.error.loadAlbums': 'Az albumok betöltése sikertelen', 'memories.error.loadAlbums': 'Az albumok betöltése sikertelen',
'memories.error.linkAlbum': 'Az album csatolása sikertelen', 'memories.error.linkAlbum': 'Az album csatolása sikertelen',
'memories.error.unlinkAlbum': 'Az album leválasztása sikertelen', 'memories.error.unlinkAlbum': 'Az album leválasztása sikertelen',
@@ -1693,6 +1804,309 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Új értesítésed érkezett', 'notif.generic.text': 'Új értesítésed érkezett',
'notif.dev.unknown_event.title': '[DEV] Ismeretlen esemény', 'notif.dev.unknown_event.title': '[DEV] Ismeretlen esemény',
'notif.dev.unknown_event.text': 'A(z) "{event}" eseménytípus nincs regisztrálva az EVENT_NOTIFICATION_CONFIG-ban', 'notif.dev.unknown_event.text': 'A(z) "{event}" eseménytípus nincs regisztrálva az EVENT_NOTIFICATION_CONFIG-ban',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'az imént',
'common.hoursAgo': '{count} órája',
'common.daysAgo': '{count} napja',
'budget.linkedToReservation': 'Foglaláshoz kapcsolva — a nevet ott módosítsd',
'packing.saveAsTemplate': 'Mentés sablonként',
'packing.templateName': 'Sablon neve',
'packing.templateSaved': 'Csomaglista sablonként mentve',
'memories.notConnectedMultipleHint': 'Csatlakoztasd valamelyik fotószolgáltatót: {provider_names} a Beállításokban, hogy fotókat adhass hozzá ehhez az úthoz.',
'memories.providerUrl': 'Szerver URL',
'memories.providerApiKey': 'API-kulcs',
'memories.providerUsername': 'Felhasználónév',
'memories.providerPassword': 'Jelszó',
'memories.saveError': 'Nem sikerült menteni a(z) {provider_name} beállításait',
'memories.saveRouteNotConfigured': 'A mentési útvonal nincs konfigurálva ehhez a szolgáltatóhoz',
'memories.testRouteNotConfigured': 'A tesztútvonal nincs konfigurálva ehhez a szolgáltatóhoz',
'memories.fillRequiredFields': 'Kérjük töltse ki az összes kötelező mezőt',
'memories.selectAlbumMultiple': 'Album kiválasztása',
'memories.selectPhotosMultiple': 'Fotók kiválasztása',
'journey.title': 'Útinaplók',
'journey.subtitle': 'Kövesse nyomon utazásait valós időben',
'journey.new': 'Új útinapló',
'journey.create': 'Létrehozás',
'journey.titlePlaceholder': 'Hová utazol?',
'journey.empty': 'Még nincsenek útinaplók',
'journey.emptyHint': 'Kezdd el dokumentálni a következő utazásod',
'journey.deleted': 'Útinapló törölve',
'journey.createError': 'Nem sikerült létrehozni az útinaplót',
'journey.deleteError': 'Nem sikerült törölni az útinaplót',
'journey.deleteConfirmTitle': 'Törlés',
'journey.deleteConfirmMessage': 'Törlöd a(z) „{title}" útinaplót? Ez nem vonható vissza.',
'journey.deleteConfirmGeneric': 'Biztosan törölni szeretnéd?',
'journey.notFound': 'Útinapló nem található',
'journey.photos': 'Fotók',
'journey.timelineEmpty': 'Még nincsenek megállók',
'journey.timelineEmptyHint': 'Adj hozzá egy bejelentkezést vagy írj naplóbejegyzést a kezdéshez',
'journey.status.draft': 'Vázlat',
'journey.status.active': 'Aktív',
'journey.status.completed': 'Befejezett',
'journey.status.upcoming': 'Közelgő',
'journey.checkin.add': 'Bejelentkezés',
'journey.checkin.namePlaceholder': 'Helyszín neve',
'journey.checkin.notesPlaceholder': 'Jegyzetek (opcionális)',
'journey.checkin.save': 'Mentés',
'journey.checkin.error': 'Nem sikerült menteni a bejelentkezést',
'journey.entry.add': 'Napló',
'journey.entry.edit': 'Bejegyzés szerkesztése',
'journey.entry.titlePlaceholder': 'Cím (opcionális)',
'journey.entry.bodyPlaceholder': 'Mi történt ma?',
'journey.entry.save': 'Mentés',
'journey.entry.error': 'Nem sikerült menteni a bejegyzést',
'journey.photo.add': 'Fotó',
'journey.photo.uploadError': 'A feltöltés sikertelen',
'journey.share.share': 'Megosztás',
'journey.share.public': 'Nyilvános',
'journey.share.linkCopied': 'Nyilvános link másolva',
'journey.share.disabled': 'Nyilvános megosztás letiltva',
'journey.editor.titlePlaceholder': 'Adj nevet ennek a pillanatnak...',
'journey.editor.bodyPlaceholder': 'Meséld el ennek a napnak a történetét...',
'journey.editor.placePlaceholder': 'Helyszín (opcionális)',
'journey.editor.tagsPlaceholder': 'Címkék: rejtett kincs, legjobb étel, újra meglátogatandó...',
'journey.visibility.private': 'Privát',
'journey.visibility.shared': 'Megosztott',
'journey.visibility.public': 'Nyilvános',
'journey.emptyState.title': 'Itt kezdődik a történeted',
'journey.emptyState.subtitle': 'Jelentkezz be egy helyszínen vagy írd meg az első naplóbejegyzésed',
'journey.frontpage.subtitle': 'Alakítsd utazásaidat történetekké, amelyeket soha nem felejtesz el',
'journey.frontpage.createJourney': 'Útinapló létrehozása',
'journey.frontpage.activeJourney': 'Aktív útinapló',
'journey.frontpage.allJourneys': 'Összes útinapló',
'journey.frontpage.journeys': 'útinapló',
'journey.frontpage.createNew': 'Új útinapló létrehozása',
'journey.frontpage.createNewSub': 'Válassz utakat, írj történeteket, oszd meg kalandjaidat',
'journey.frontpage.live': 'Élő',
'journey.frontpage.synced': 'Szinkronizálva',
'journey.frontpage.continueWriting': 'Írás folytatása',
'journey.frontpage.updated': 'Frissítve: {time}',
'journey.frontpage.suggestionLabel': 'Az út épp véget ért',
'journey.frontpage.suggestionText': 'Alakítsd a(z) <strong>{title}</strong> útinaplóvá',
'journey.frontpage.dismiss': 'Elvetés',
'journey.frontpage.journeyName': 'Útinapló neve',
'journey.frontpage.namePlaceholder': 'pl. Délkelet-Ázsia 2026',
'journey.frontpage.selectTrips': 'Utak kiválasztása',
'journey.frontpage.tripsSelected': 'út kiválasztva',
'journey.frontpage.trips': 'út',
'journey.frontpage.placesImported': 'helyszín importálásra kerül',
'journey.frontpage.places': 'helyszín',
'journey.detail.backToJourney': 'Vissza az útinaplóhoz',
'journey.detail.syncedWithTrips': 'Szinkronizálva az utakkal',
'journey.detail.addEntry': 'Bejegyzés hozzáadása',
'journey.detail.newEntry': 'Új bejegyzés',
'journey.detail.editEntry': 'Bejegyzés szerkesztése',
'journey.detail.noEntries': 'Még nincsenek bejegyzések',
'journey.detail.noEntriesHint': 'Adj hozzá egy utat a vázlatos bejegyzések elkészítéséhez',
'journey.detail.noPhotos': 'Még nincsenek fotók',
'journey.detail.noPhotosHint': 'Tölts fel fotókat a bejegyzésekhez vagy böngészd az Immich/Synology könyvtárat',
'journey.detail.journeyStats': 'Útinapló statisztika',
'journey.detail.syncedTrips': 'Szinkronizált utak',
'journey.detail.noTripsLinked': 'Még nincsenek kapcsolt utak',
'journey.detail.contributors': 'Közreműködők',
'journey.detail.readMore': 'Tovább olvasás',
'journey.detail.prosCons': 'Előnyök és hátrányok',
'journey.stats.days': 'Napok',
'journey.stats.cities': 'Városok',
'journey.stats.entries': 'Bejegyzések',
'journey.stats.photos': 'Fotók',
'journey.stats.places': 'Helyszínek',
'journey.verdict.lovedIt': 'Imádtam',
'journey.verdict.couldBeBetter': 'Lehetne jobb',
'journey.synced.places': 'helyszín',
'journey.synced.synced': 'szinkronizálva',
'journey.editor.uploadPhotos': 'Fotók feltöltése',
'journey.editor.fromGallery': 'Galériából',
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
'journey.editor.writeStory': 'Írd meg a történeted...',
'journey.editor.prosCons': 'Előnyök és hátrányok',
'journey.editor.pros': 'Előnyök',
'journey.editor.cons': 'Hátrányok',
'journey.editor.proPlaceholder': 'Valami remek...',
'journey.editor.conPlaceholder': 'Nem annyira jó...',
'journey.editor.addAnother': 'Még egy hozzáadása',
'journey.editor.date': 'Dátum',
'journey.editor.location': 'Helyszín',
'journey.editor.searchLocation': 'Helyszín keresése...',
'journey.editor.mood': 'Hangulat',
'journey.editor.weather': 'Időjárás',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Legyen az 1.',
'journey.mood.amazing': 'Fantasztikus',
'journey.mood.good': 'Jó',
'journey.mood.neutral': 'Semleges',
'journey.mood.rough': 'Nehéz',
'journey.weather.sunny': 'Napos',
'journey.weather.partly': 'Részben felhős',
'journey.weather.cloudy': 'Felhős',
'journey.weather.rainy': 'Esős',
'journey.weather.stormy': 'Viharos',
'journey.weather.cold': 'Havas',
'journey.trips.linkTrip': 'Út kapcsolása',
'journey.trips.searchTrip': 'Út keresése',
'journey.trips.searchPlaceholder': 'Út neve vagy úti cél...',
'journey.trips.noTripsAvailable': 'Nincsenek elérhető utak',
'journey.trips.link': 'Kapcsolás',
'journey.trips.tripLinked': 'Út kapcsolva',
'journey.trips.linkFailed': 'Nem sikerült az utat kapcsolni',
'journey.trips.addTrip': 'Út hozzáadása',
'journey.trips.unlinkTrip': 'Út leválasztása',
'journey.trips.unlinkMessage': 'Leválasztod a(z) „{title}" utat? Az összes szinkronizált bejegyzés és fotó véglegesen törlődik. Ez nem vonható vissza.',
'journey.trips.unlink': 'Leválasztás',
'journey.trips.tripUnlinked': 'Út leválasztva',
'journey.trips.unlinkFailed': 'Nem sikerült az utat leválasztani',
'journey.trips.noTripsLinkedSettings': 'Nincsenek kapcsolt utak',
'journey.contributors.invite': 'Közreműködő meghívása',
'journey.contributors.searchUser': 'Felhasználó keresése',
'journey.contributors.searchPlaceholder': 'Felhasználónév vagy e-mail...',
'journey.contributors.noUsers': 'Nem található felhasználó',
'journey.contributors.role': 'Szerep',
'journey.contributors.added': 'Közreműködő hozzáadva',
'journey.contributors.addFailed': 'Nem sikerült hozzáadni a közreműködőt',
'journey.share.publicShare': 'Nyilvános megosztás',
'journey.share.createLink': 'Megosztó link létrehozása',
'journey.share.linkCreated': 'Megosztó link létrehozva',
'journey.share.createFailed': 'Nem sikerült létrehozni a linket',
'journey.share.copy': 'Másolás',
'journey.share.copied': 'Másolva!',
'journey.share.timeline': 'Idővonal',
'journey.share.gallery': 'Galéria',
'journey.share.map': 'Térkép',
'journey.share.removeLink': 'Megosztó link eltávolítása',
'journey.share.linkDeleted': 'Megosztó link törölve',
'journey.share.deleteFailed': 'Nem sikerült törölni',
'journey.share.updateFailed': 'Nem sikerült frissíteni',
'journey.settings.title': 'Útinapló beállításai',
'journey.settings.coverImage': 'Borítókép',
'journey.settings.changeCover': 'Borító módosítása',
'journey.settings.addCover': 'Borítókép hozzáadása',
'journey.settings.name': 'Név',
'journey.settings.subtitle': 'Alcím',
'journey.settings.subtitlePlaceholder': 'pl. Thaiföld, Vietnam és Kambodzsa',
'journey.settings.delete': 'Törlés',
'journey.settings.deleteJourney': 'Útinapló törlése',
'journey.settings.deleteMessage': 'Törlöd a(z) „{title}" útinaplót? Minden bejegyzés és fotó elveszik.',
'journey.settings.saved': 'Beállítások mentve',
'journey.settings.saveFailed': 'Nem sikerült menteni',
'journey.settings.coverUpdated': 'Borítókép frissítve',
'journey.settings.coverFailed': 'A feltöltés sikertelen',
'journey.settings.failedToDelete': 'Törlés sikertelen',
'journey.entries.deleteTitle': 'Bejegyzés törlése',
'journey.photosUploaded': '{count} fotó feltöltve',
'journey.photosAdded': '{count} fotó hozzáadva',
'journey.public.notFound': 'Nem található',
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
'journey.public.readOnly': 'Csak olvasható · Nyilvános útinapló',
'journey.public.tagline': 'Utazástervező és felfedező eszköz',
'journey.public.sharedVia': 'Megosztva a következőn keresztül:',
'journey.public.madeWith': 'Készítve a következővel:',
'journey.pdf.journeyBook': 'Útinaplókönyv',
'journey.pdf.madeWith': 'Készítve a TREK segítségével',
'journey.pdf.day': 'Nap',
'journey.pdf.theEnd': 'Vége',
'journey.pdf.saveAsPdf': 'Mentés PDF-ként',
'journey.pdf.pages': 'oldal',
'dashboard.greeting.morning': 'Jó reggelt,',
'dashboard.greeting.afternoon': 'Jó napot,',
'dashboard.greeting.evening': 'Jó estét,',
'dashboard.mobile.liveNow': 'Most élőben',
'dashboard.mobile.tripProgress': 'Út előrehaladása',
'dashboard.mobile.daysLeft': 'még {count} nap',
'dashboard.mobile.places': 'Helyszínek',
'dashboard.mobile.buddies': 'Útitársak',
'dashboard.mobile.newTrip': 'Új út',
'dashboard.mobile.currency': 'Pénznem',
'dashboard.mobile.timezone': 'Időzóna',
'dashboard.mobile.upcomingTrips': 'Közelgő utak',
'dashboard.mobile.yourTrips': 'Utaid',
'dashboard.mobile.trips': 'út',
'dashboard.mobile.starts': 'Kezdés',
'dashboard.mobile.duration': 'Időtartam',
'dashboard.mobile.day': 'nap',
'dashboard.mobile.days': 'nap',
'dashboard.mobile.ongoing': 'Folyamatban',
'dashboard.mobile.startsToday': 'Ma kezdődik',
'dashboard.mobile.tomorrow': 'Holnap',
'dashboard.mobile.inDays': '{count} nap múlva',
'dashboard.mobile.inMonths': '{count} hónap múlva',
'dashboard.mobile.completed': 'Befejezett',
'dashboard.mobile.currencyConverter': 'Pénznemváltó',
'nav.profile': 'Profil',
'nav.bottomSettings': 'Beállítások',
'nav.bottomAdmin': 'Adminisztráció',
'nav.bottomLogout': 'Kijelentkezés',
'nav.bottomAdminBadge': 'Admin',
'dayplan.mobile.addPlace': 'Helyszín hozzáadása',
'dayplan.mobile.searchPlaces': 'Helyszínek keresése...',
'dayplan.mobile.allAssigned': 'Minden helyszín kiosztva',
'dayplan.mobile.noMatch': 'Nincs találat',
'dayplan.mobile.createNew': 'Új helyszín létrehozása',
'admin.addons.catalog.journey.name': 'Útinaplók',
'admin.addons.catalog.journey.description': 'Utazáskövetés és útinapló bejelentkezésekkel, fotókkal és napi történetekkel',
// OAuth scope groups
'oauth.scope.group.trips': 'Utazások',
'oauth.scope.group.places': 'Helyek',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Csomagolás',
'oauth.scope.group.todos': 'Feladatok',
'oauth.scope.group.budget': 'Költségvetés',
'oauth.scope.group.reservations': 'Foglalások',
'oauth.scope.group.collab': 'Együttműködés',
'oauth.scope.group.notifications': 'Értesítések',
'oauth.scope.group.vacay': 'Szabadság',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Időjárás',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Utazások és útvonalak megtekintése',
'oauth.scope.trips:read.description': 'Utazások, napok, napi feljegyzések és tagok olvasása',
'oauth.scope.trips:write.label': 'Utazások és útvonalak szerkesztése',
'oauth.scope.trips:write.description': 'Utazások, napok és feljegyzések létrehozása, frissítése és tagok kezelése',
'oauth.scope.trips:delete.label': 'Utazások törlése',
'oauth.scope.trips:delete.description': 'Teljes utazások végleges törlése — ez a művelet visszafordíthatatlan',
'oauth.scope.trips:share.label': 'Megosztási linkek kezelése',
'oauth.scope.trips:share.description': 'Nyilvános megosztási linkek létrehozása, frissítése és visszavonása',
'oauth.scope.places:read.label': 'Helyek és térképadatok megtekintése',
'oauth.scope.places:read.description': 'Helyek, napi hozzárendelések, címkék és kategóriák olvasása',
'oauth.scope.places:write.label': 'Helyek kezelése',
'oauth.scope.places:write.description': 'Helyek, hozzárendelések és címkék létrehozása, frissítése és törlése',
'oauth.scope.atlas:read.label': 'Atlas megtekintése',
'oauth.scope.atlas:read.description': 'Meglátogatott országok, régiók és bakancslisták olvasása',
'oauth.scope.atlas:write.label': 'Atlas kezelése',
'oauth.scope.atlas:write.description': 'Országok és régiók meglátogatottként jelölése, bakancslisták kezelése',
'oauth.scope.packing:read.label': 'Csomaglisták megtekintése',
'oauth.scope.packing:read.description': 'Csomagolási tételek, táskák és kategória-hozzárendelések olvasása',
'oauth.scope.packing:write.label': 'Csomaglisták kezelése',
'oauth.scope.packing:write.description': 'Csomagolási tételek és táskák hozzáadása, frissítése, törlése, jelölése és átrendezése',
'oauth.scope.todos:read.label': 'Feladatlisták megtekintése',
'oauth.scope.todos:read.description': 'Utazás feladatai és kategória-hozzárendelések olvasása',
'oauth.scope.todos:write.label': 'Feladatlisták kezelése',
'oauth.scope.todos:write.description': 'Feladatok létrehozása, frissítése, jelölése, törlése és átrendezése',
'oauth.scope.budget:read.label': 'Költségvetés megtekintése',
'oauth.scope.budget:read.description': 'Költségvetési tételek és kiadások részletezésének olvasása',
'oauth.scope.budget:write.label': 'Költségvetés kezelése',
'oauth.scope.budget:write.description': 'Költségvetési tételek létrehozása, frissítése és törlése',
'oauth.scope.reservations:read.label': 'Foglalások megtekintése',
'oauth.scope.reservations:read.description': 'Foglalások és szállásadatok olvasása',
'oauth.scope.reservations:write.label': 'Foglalások kezelése',
'oauth.scope.reservations:write.description': 'Foglalások létrehozása, frissítése, törlése és átrendezése',
'oauth.scope.collab:read.label': 'Együttműködés megtekintése',
'oauth.scope.collab:read.description': 'Együttműködési feljegyzések, szavazások és üzenetek olvasása',
'oauth.scope.collab:write.label': 'Együttműködés kezelése',
'oauth.scope.collab:write.description': 'Együttműködési feljegyzések, szavazások és üzenetek létrehozása, frissítése és törlése',
'oauth.scope.notifications:read.label': 'Értesítések megtekintése',
'oauth.scope.notifications:read.description': 'Alkalmazáson belüli értesítések és olvasatlan számok olvasása',
'oauth.scope.notifications:write.label': 'Értesítések kezelése',
'oauth.scope.notifications:write.description': 'Értesítések olvasottként jelölése és válaszadás rájuk',
'oauth.scope.vacay:read.label': 'Szabadságtervek megtekintése',
'oauth.scope.vacay:read.description': 'Szabadságtervezési adatok, bejegyzések és statisztikák olvasása',
'oauth.scope.vacay:write.label': 'Szabadságtervek kezelése',
'oauth.scope.vacay:write.description': 'Szabadságbejegyzések, ünnepnapok és csapattervek létrehozása és kezelése',
'oauth.scope.geo:read.label': 'Térképek és geokódolás',
'oauth.scope.geo:read.description': 'Helyek keresése, térkép URL-ek feloldása és koordináták fordított geokódolása',
'oauth.scope.weather:read.label': 'Időjárás-előrejelzések',
'oauth.scope.weather:read.description': 'Időjárás-előrejelzések lekérése az utazási helyszínekre és dátumokra',
} }
export default hu export default hu
+427 -13
View File
@@ -8,6 +8,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.loading': 'Caricamento...', 'common.loading': 'Caricamento...',
'common.import': 'Importa', 'common.import': 'Importa',
'common.error': 'Errore', 'common.error': 'Errore',
'common.unknownError': 'Errore sconosciuto',
'common.tooManyAttempts': 'Troppi tentativi. Riprova più tardi.',
'common.back': 'Indietro', 'common.back': 'Indietro',
'common.all': 'Tutti', 'common.all': 'Tutti',
'common.close': 'Chiudi', 'common.close': 'Chiudi',
@@ -27,6 +29,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.password': 'Password', 'common.password': 'Password',
'common.saving': 'Salvataggio...', 'common.saving': 'Salvataggio...',
'common.saved': 'Salvato', 'common.saved': 'Salvato',
'trips.memberRemoved': '{username} rimosso',
'trips.memberRemoveError': 'Rimozione non riuscita',
'trips.memberAdded': '{username} aggiunto',
'trips.memberAddError': 'Aggiunta non riuscita',
'common.expand': 'Espandi',
'common.collapse': 'Comprimi',
'trips.reminder': 'Promemoria', 'trips.reminder': 'Promemoria',
'trips.reminderNone': 'Nessuno', 'trips.reminderNone': 'Nessuno',
'trips.reminderDay': 'giorno', 'trips.reminderDay': 'giorno',
@@ -180,6 +188,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Configurazione client', 'settings.mcp.clientConfig': 'Configurazione client',
'settings.mcp.clientConfigHint': 'Sostituisci <your_token> con un token API dalla lista sottostante. Il percorso di npx potrebbe dover essere adattato per il tuo sistema (es. C:\\PROGRA~1\\nodejs\\npx.cmd su Windows).', 'settings.mcp.clientConfigHint': 'Sostituisci <your_token> con un token API dalla lista sottostante. Il percorso di npx potrebbe dover essere adattato per il tuo sistema (es. C:\\PROGRA~1\\nodejs\\npx.cmd su Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Copia', 'settings.mcp.copy': 'Copia',
'settings.mcp.copied': 'Copiato!', 'settings.mcp.copied': 'Copiato!',
'settings.mcp.apiTokens': 'Token API', 'settings.mcp.apiTokens': 'Token API',
@@ -201,6 +210,48 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'Impossibile creare il token', 'settings.mcp.toast.createError': 'Impossibile creare il token',
'settings.mcp.toast.deleted': 'Token eliminato', 'settings.mcp.toast.deleted': 'Token eliminato',
'settings.mcp.toast.deleteError': 'Impossibile eliminare il token', 'settings.mcp.toast.deleteError': 'Impossibile eliminare il token',
'settings.mcp.apiTokensDeprecated': 'I token API sono deprecati e verranno rimossi in una versione futura. Utilizza invece i client OAuth 2.1.',
'settings.oauth.clients': 'Client OAuth 2.1',
'settings.oauth.clientsHint': 'Registra client OAuth 2.1 per consentire alle applicazioni MCP di terze parti (Claude Web, Cursor, ecc.) di connettersi senza token statici.',
'settings.oauth.createClient': 'Nuovo client',
'settings.oauth.noClients': 'Nessun client OAuth registrato.',
'settings.oauth.clientId': 'ID client',
'settings.oauth.clientSecret': 'Segreto client',
'settings.oauth.deleteClient': 'Elimina client',
'settings.oauth.deleteClientMessage': 'Questo client e tutte le sessioni attive verranno eliminati definitivamente. Qualsiasi applicazione che lo utilizza perderà immediatamente l\'accesso.',
'settings.oauth.rotateSecret': 'Rinnova segreto',
'settings.oauth.rotateSecretMessage': 'Verrà generato un nuovo segreto client e tutte le sessioni esistenti verranno invalidate immediatamente. Aggiorna la tua applicazione prima di chiudere questa finestra.',
'settings.oauth.rotateSecretConfirm': 'Rinnova',
'settings.oauth.rotateSecretConfirming': 'Rinnovo in corso…',
'settings.oauth.rotateSecretDoneTitle': 'Nuovo segreto generato',
'settings.oauth.rotateSecretDoneWarning': 'Questo segreto viene mostrato una sola volta. Copialo ora e aggiorna la tua applicazione — tutte le sessioni precedenti sono state invalidate.',
'settings.oauth.activeSessions': 'Sessioni OAuth attive',
'settings.oauth.sessionScopes': 'Ambiti',
'settings.oauth.sessionExpires': 'Scade',
'settings.oauth.revoke': 'Revoca',
'settings.oauth.revokeSession': 'Revoca sessione',
'settings.oauth.revokeSessionMessage': 'Questo revocherà immediatamente l\'accesso per questa sessione OAuth.',
'settings.oauth.modal.createTitle': 'Registra client OAuth',
'settings.oauth.modal.presets': 'Preimpostazioni rapide',
'settings.oauth.modal.clientName': 'Nome applicazione',
'settings.oauth.modal.clientNamePlaceholder': 'es. Claude Web, La mia app MCP',
'settings.oauth.modal.redirectUris': 'URI di reindirizzamento',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Un URI per riga. HTTPS richiesto (localhost esente). Corrispondenza esatta richiesta.',
'settings.oauth.modal.scopes': 'Ambiti consentiti',
'settings.oauth.modal.scopesHint': 'list_trips e get_trip_summary sono sempre disponibili — nessun ambito richiesto. Permettono all\'IA di scoprire gli ID viaggio necessari.',
'settings.oauth.modal.selectAll': 'Seleziona tutto',
'settings.oauth.modal.deselectAll': 'Deseleziona tutto',
'settings.oauth.modal.creating': 'Registrazione…',
'settings.oauth.modal.create': 'Registra client',
'settings.oauth.modal.createdTitle': 'Client registrato',
'settings.oauth.modal.createdWarning': 'Il segreto client viene mostrato una sola volta. Copialo ora — non può essere recuperato.',
'settings.oauth.toast.createError': 'Impossibile registrare il client OAuth',
'settings.oauth.toast.deleted': 'Client OAuth eliminato',
'settings.oauth.toast.deleteError': 'Impossibile eliminare il client OAuth',
'settings.oauth.toast.revoked': 'Sessione revocata',
'settings.oauth.toast.revokeError': 'Impossibile revocare la sessione',
'settings.oauth.toast.rotateError': 'Impossibile rinnovare il segreto client',
'settings.account': 'Account', 'settings.account': 'Account',
'settings.about': 'Informazioni', 'settings.about': 'Informazioni',
'settings.about.reportBug': 'Segnala un bug', 'settings.about.reportBug': 'Segnala un bug',
@@ -211,7 +262,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.', 'settings.about.description': 'TREK è un pianificatore di viaggi self-hosted che ti aiuta a organizzare i tuoi viaggi dalla prima idea all\'ultimo ricordo. Pianificazione giornaliera, budget, liste bagagli, foto e molto altro — tutto in un unico posto, sul tuo server.',
'settings.about.madeWith': 'Fatto con', 'settings.about.madeWith': 'Fatto con',
'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.', 'settings.about.madeBy': 'da Maurice e una crescente comunità open-source.',
'settings.username': 'Username', 'settings.username': 'Nome utente',
'settings.email': 'Email', 'settings.email': 'Email',
'settings.role': 'Ruolo', 'settings.role': 'Ruolo',
'settings.roleAdmin': 'Amministratore', 'settings.roleAdmin': 'Amministratore',
@@ -274,9 +325,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.none': 'Disattivato', 'admin.notifications.none': 'Disattivato',
'admin.notifications.email': 'E-mail (SMTP)', 'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Eventi di notifica',
'admin.notifications.eventsHint': 'Scegli quali eventi attivano le notifiche per tutti gli utenti.',
'admin.notifications.configureFirst': 'Configura prima le impostazioni SMTP o webhook qui sotto, poi abilita gli eventi.',
'admin.notifications.save': 'Salva impostazioni notifiche', 'admin.notifications.save': 'Salva impostazioni notifiche',
'admin.notifications.saved': 'Impostazioni notifiche salvate', 'admin.notifications.saved': 'Impostazioni notifiche salvate',
'admin.notifications.testWebhook': 'Invia webhook di test', 'admin.notifications.testWebhook': 'Invia webhook di test',
@@ -354,7 +402,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.hasAccount': 'Hai già un account?', 'login.hasAccount': 'Hai già un account?',
'login.register': 'Registrati', 'login.register': 'Registrati',
'login.emailPlaceholder': 'tua@email.com', 'login.emailPlaceholder': 'tua@email.com',
'login.username': 'Username', 'login.username': 'Nome utente',
'login.oidc.registrationDisabled': 'La registrazione è disabilitata. Contatta il tuo amministratore.', 'login.oidc.registrationDisabled': 'La registrazione è disabilitata. Contatta il tuo amministratore.',
'login.oidc.noEmail': 'Nessuna email ricevuta dal provider.', 'login.oidc.noEmail': 'Nessuna email ricevuta dal provider.',
'login.oidc.tokenFailed': 'Autenticazione fallita.', 'login.oidc.tokenFailed': 'Autenticazione fallita.',
@@ -371,6 +419,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.mfaHint': 'Apri Google Authenticator, Authy o un\'altra app TOTP.', 'login.mfaHint': 'Apri Google Authenticator, Authy o un\'altra app TOTP.',
'login.mfaBack': '← Torna all\'accesso', 'login.mfaBack': '← Torna all\'accesso',
'login.mfaVerify': 'Verifica', 'login.mfaVerify': 'Verifica',
'login.invalidInviteLink': 'Link di invito non valido o scaduto',
'login.oidcFailed': 'Accesso OIDC non riuscito',
'login.usernameRequired': 'Il nome utente è obbligatorio',
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
// Register // Register
'register.passwordMismatch': 'Le password non corrispondono', 'register.passwordMismatch': 'Le password non corrispondono',
@@ -449,6 +501,17 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Impostazioni', 'admin.tabs.settings': 'Impostazioni',
'admin.allowRegistration': 'Consenti Registrazione', 'admin.allowRegistration': 'Consenti Registrazione',
'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente', 'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)', 'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)',
'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.', 'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.',
'admin.apiKeys': 'Chiavi API', 'admin.apiKeys': 'Chiavi API',
@@ -561,9 +624,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.audit.col.details': 'Dettagli', 'admin.audit.col.details': 'Dettagli',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'Token MCP', 'admin.tabs.mcpTokens': 'Accesso MCP',
'admin.mcpTokens.title': 'Token MCP', 'admin.mcpTokens.title': 'Accesso MCP',
'admin.mcpTokens.subtitle': 'Gestisci i token API di tutti gli utenti', 'admin.mcpTokens.subtitle': 'Gestisci le sessioni OAuth e i token API di tutti gli utenti',
'admin.mcpTokens.sectionTitle': 'Token API',
'admin.mcpTokens.owner': 'Proprietario', 'admin.mcpTokens.owner': 'Proprietario',
'admin.mcpTokens.tokenName': 'Nome token', 'admin.mcpTokens.tokenName': 'Nome token',
'admin.mcpTokens.created': 'Creato', 'admin.mcpTokens.created': 'Creato',
@@ -575,6 +639,17 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.mcpTokens.deleteSuccess': 'Token eliminato', 'admin.mcpTokens.deleteSuccess': 'Token eliminato',
'admin.mcpTokens.deleteError': 'Impossibile eliminare il token', 'admin.mcpTokens.deleteError': 'Impossibile eliminare il token',
'admin.mcpTokens.loadError': 'Impossibile caricare i token', 'admin.mcpTokens.loadError': 'Impossibile caricare i token',
'admin.oauthSessions.sectionTitle': 'Sessioni OAuth',
'admin.oauthSessions.clientName': 'Client',
'admin.oauthSessions.owner': 'Proprietario',
'admin.oauthSessions.scopes': 'Ambiti',
'admin.oauthSessions.created': 'Creato',
'admin.oauthSessions.empty': 'Nessuna sessione OAuth attiva',
'admin.oauthSessions.revokeTitle': 'Revoca sessione',
'admin.oauthSessions.revokeMessage': 'Questa sessione OAuth verrà revocata immediatamente. Il client perderà l\'accesso MCP.',
'admin.oauthSessions.revokeSuccess': 'Sessione revocata',
'admin.oauthSessions.revokeError': 'Impossibile revocare la sessione',
'admin.oauthSessions.loadError': 'Impossibile caricare le sessioni OAuth',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -661,6 +736,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'vacay.companyHolidays': 'Ferie aziendali', 'vacay.companyHolidays': 'Ferie aziendali',
'vacay.companyHolidaysHint': 'Consenti di segnare giorni di ferie aziendali', 'vacay.companyHolidaysHint': 'Consenti di segnare giorni di ferie aziendali',
'vacay.companyHolidaysNoDeduct': 'Le ferie aziendali non vengono conteggiate nei giorni di ferie.', 'vacay.companyHolidaysNoDeduct': 'Le ferie aziendali non vengono conteggiate nei giorni di ferie.',
'vacay.weekStart': 'La settimana inizia il',
'vacay.weekStartHint': 'Scegli se la settimana inizia il lunedì o la domenica',
'vacay.carryOver': 'Riporto', 'vacay.carryOver': 'Riporto',
'vacay.carryOverHint': 'Riporta automaticamente i giorni di ferie rimanenti all\'anno successivo', 'vacay.carryOverHint': 'Riporta automaticamente i giorni di ferie rimanenti all\'anno successivo',
'vacay.sharing': 'Condivisione', 'vacay.sharing': 'Condivisione',
@@ -871,6 +948,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'inspector.files': 'File', 'inspector.files': 'File',
'inspector.filesCount': '{count} file', 'inspector.filesCount': '{count} file',
'inspector.removeFromDay': 'Rimuovi dal giorno', 'inspector.removeFromDay': 'Rimuovi dal giorno',
'inspector.remove': 'Rimuovi',
'inspector.addToDay': 'Aggiungi al giorno', 'inspector.addToDay': 'Aggiungi al giorno',
'inspector.confirmedRes': 'Prenotazione confermata', 'inspector.confirmedRes': 'Prenotazione confermata',
'inspector.pendingRes': 'Prenotazione in attesa', 'inspector.pendingRes': 'Prenotazione in attesa',
@@ -1011,6 +1089,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'budget.totalBudget': 'Budget totale', 'budget.totalBudget': 'Budget totale',
'budget.byCategory': 'Per categoria', 'budget.byCategory': 'Per categoria',
'budget.editTooltip': 'Clicca per modificare', 'budget.editTooltip': 'Clicca per modificare',
'budget.linkedToReservation': 'Collegato a una prenotazione — modifica il nome lì',
'budget.confirm.deleteCategory': 'Sei sicuro di voler eliminare la categoria "{name}" con {count} voci?', 'budget.confirm.deleteCategory': 'Sei sicuro di voler eliminare la categoria "{name}" con {count} voci?',
'budget.deleteCategory': 'Elimina categoria', 'budget.deleteCategory': 'Elimina categoria',
'budget.perPerson': 'Per persona', 'budget.perPerson': 'Per persona',
@@ -1020,9 +1099,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'budget.settlement': 'Regolamento', 'budget.settlement': 'Regolamento',
'budget.settlementInfo': 'Clicca sull\'avatar di un membro su una voce di budget per contrassegnarlo in verde — significa che ha pagato. Il regolamento mostra poi chi deve quanto a chi.', 'budget.settlementInfo': 'Clicca sull\'avatar di un membro su una voce di budget per contrassegnarlo in verde — significa che ha pagato. Il regolamento mostra poi chi deve quanto a chi.',
'budget.netBalances': 'Saldi netti', 'budget.netBalances': 'Saldi netti',
'budget.linkedToReservation': 'Collegato a una prenotazione — modifica il nome lì',
// Files // Files
'files.title': 'File', 'files.title': 'File',
'files.pageTitle': 'File e documenti',
'files.subtitle': '{count} file per {trip}',
'files.downloadPdf': 'Scarica PDF',
'files.count': '{count} file', 'files.count': '{count} file',
'files.countSingular': '1 documento', 'files.countSingular': '1 documento',
'files.uploaded': '{count} caricati', 'files.uploaded': '{count} caricati',
@@ -1101,7 +1184,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'packing.menuCheckAll': 'Seleziona tutti', 'packing.menuCheckAll': 'Seleziona tutti',
'packing.menuUncheckAll': 'Deseleziona tutti', 'packing.menuUncheckAll': 'Deseleziona tutti',
'packing.menuDeleteCat': 'Elimina categoria', 'packing.menuDeleteCat': 'Elimina categoria',
'packing.assignUser': 'Assegna utente',
'packing.noMembers': 'Nessun membro del viaggio', 'packing.noMembers': 'Nessun membro del viaggio',
'packing.addItem': 'Aggiungi elemento', 'packing.addItem': 'Aggiungi elemento',
'packing.addItemPlaceholder': 'Nome elemento...', 'packing.addItemPlaceholder': 'Nome elemento...',
@@ -1111,6 +1193,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'packing.template': 'Modello', 'packing.template': 'Modello',
'packing.templateApplied': '{count} elementi aggiunti dal modello', 'packing.templateApplied': '{count} elementi aggiunti dal modello',
'packing.templateError': 'Impossibile applicare il modello', 'packing.templateError': 'Impossibile applicare il modello',
'packing.saveAsTemplate': 'Salva come modello',
'packing.templateName': 'Nome modello',
'packing.templateSaved': 'Lista bagagli salvata come modello',
'packing.bags': 'Valigie', 'packing.bags': 'Valigie',
'packing.noBag': 'Non assegnato', 'packing.noBag': 'Non assegnato',
'packing.totalWeight': 'Peso totale', 'packing.totalWeight': 'Peso totale',
@@ -1266,6 +1351,13 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'backup.keep.forever': 'Conserva per sempre', 'backup.keep.forever': 'Conserva per sempre',
// Photos // Photos
'photos.title': 'Foto',
'photos.subtitle': '{count} foto per {trip}',
'photos.dropHere': 'Trascina le foto qui...',
'photos.dropHereActive': 'Trascina le foto qui',
'photos.captionForAll': 'Didascalia (per tutti)',
'photos.captionPlaceholder': 'Didascalia opzionale...',
'photos.addCaption': 'Aggiungi didascalia...',
'photos.allDays': 'Tutti i giorni', 'photos.allDays': 'Tutti i giorni',
'photos.noPhotos': 'Ancora nessuna foto', 'photos.noPhotos': 'Ancora nessuna foto',
'photos.uploadHint': 'Carica le foto del tuo viaggio', 'photos.uploadHint': 'Carica le foto del tuo viaggio',
@@ -1273,6 +1365,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'photos.linkPlace': 'Collega luogo', 'photos.linkPlace': 'Collega luogo',
'photos.noPlace': 'Nessun luogo', 'photos.noPlace': 'Nessun luogo',
'photos.uploadN': 'Caricamento di {n} foto', 'photos.uploadN': 'Caricamento di {n} foto',
'photos.linkDay': 'Collega giorno',
'photos.noDay': 'Nessun giorno',
'photos.dayLabel': 'Giorno {number}',
'photos.photoSelected': 'Foto selezionata',
'photos.photosSelected': 'Foto selezionate',
'photos.fileTypeHint': 'JPG, PNG, WebP · max. 10 MB · fino a 30 foto',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Ripristinare il backup?', 'backup.restoreConfirmTitle': 'Ripristinare il backup?',
@@ -1299,6 +1397,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'planner.routeCalculated': 'Percorso calcolato', 'planner.routeCalculated': 'Percorso calcolato',
'planner.routeCalcFailed': 'Il percorso non è stato calcolato', 'planner.routeCalcFailed': 'Il percorso non è stato calcolato',
'planner.routeError': 'Errore nel calcolo del percorso', 'planner.routeError': 'Errore nel calcolo del percorso',
'planner.icsExportFailed': 'Esportazione ICS non riuscita',
'planner.routeOptimized': 'Percorso ottimizzato', 'planner.routeOptimized': 'Percorso ottimizzato',
'planner.reservationUpdated': 'Prenotazione aggiornata', 'planner.reservationUpdated': 'Prenotazione aggiornata',
'planner.reservationAdded': 'Prenotazione aggiunta', 'planner.reservationAdded': 'Prenotazione aggiunta',
@@ -1384,6 +1483,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Foto', 'memories.title': 'Foto',
'memories.notConnected': 'Immich non connesso', 'memories.notConnected': 'Immich non connesso',
'memories.notConnectedHint': 'Connetti la tua istanza Immich nelle Impostazioni per vedere qui le foto del tuo viaggio.', 'memories.notConnectedHint': 'Connetti la tua istanza Immich nelle Impostazioni per vedere qui le foto del tuo viaggio.',
'memories.notConnectedMultipleHint': 'Collega uno di questi provider di foto: {provider_names} nelle Impostazioni per poter aggiungere foto a questo viaggio.',
'memories.noDates': 'Aggiungi le date al tuo viaggio per caricare le foto.', 'memories.noDates': 'Aggiungi le date al tuo viaggio per caricare le foto.',
'memories.noPhotos': 'Nessuna foto trovata', 'memories.noPhotos': 'Nessuna foto trovata',
'memories.noPhotosHint': 'Nessuna foto trovata in Immich per l\'intervallo di date di questo viaggio.', 'memories.noPhotosHint': 'Nessuna foto trovata in Immich per l\'intervallo di date di questo viaggio.',
@@ -1394,23 +1494,35 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Rivedi le tue foto', 'memories.reviewTitle': 'Rivedi le tue foto',
'memories.reviewHint': 'Clicca sulle foto per escluderle dalla condivisione.', 'memories.reviewHint': 'Clicca sulle foto per escluderle dalla condivisione.',
'memories.shareCount': 'Condividi {count} foto', 'memories.shareCount': 'Condividi {count} foto',
'memories.immichUrl': 'URL Server Immich', 'memories.providerUrl': 'URL del server',
'memories.immichApiKey': 'Chiave API', 'memories.providerApiKey': 'Chiave API',
'memories.providerUsername': 'Nome utente',
'memories.providerPassword': 'Password',
'memories.providerOTP': 'Codice MFA (se abilitato)',
'memories.skipSSLVerification': 'Ignora la verifica del certificato SSL',
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
'memories.testConnection': 'Test connessione', 'memories.testConnection': 'Test connessione',
'memories.testFirst': 'Testa prima la connessione', 'memories.testFirst': 'Testa prima la connessione',
'memories.connected': 'Connesso', 'memories.connected': 'Connesso',
'memories.disconnected': 'Non connesso', 'memories.disconnected': 'Non connesso',
'memories.connectionSuccess': 'Connesso a Immich', 'memories.connectionSuccess': 'Connesso a Immich',
'memories.connectionError': 'Impossibile connettersi a Immich', 'memories.connectionError': 'Impossibile connettersi a Immich',
'memories.saved': 'Impostazioni Immich salvate', 'memories.saved': 'Impostazioni {provider_name} salvate',
'memories.providerDisconnectedBanner': 'La connessione a {provider_name} è persa. Riconnetti nelle Impostazioni per visualizzare le foto.',
'memories.saveError': 'Impossibile salvare le impostazioni di {provider_name}',
'memories.saveRouteNotConfigured': 'La route di salvataggio non è configurata per questo provider',
'memories.testRouteNotConfigured': 'La route di test non è configurata per questo provider',
'memories.fillRequiredFields': 'Per favore compila tutti i campi obbligatori',
'memories.addPhotos': 'Aggiungi foto', 'memories.addPhotos': 'Aggiungi foto',
'memories.linkAlbum': 'Collega album', 'memories.linkAlbum': 'Collega album',
'memories.selectAlbum': 'Seleziona album Immich', 'memories.selectAlbum': 'Seleziona album Immich',
'memories.selectAlbumMultiple': 'Seleziona album',
'memories.noAlbums': 'Nessun album trovato', 'memories.noAlbums': 'Nessun album trovato',
'memories.syncAlbum': 'Sincronizza album', 'memories.syncAlbum': 'Sincronizza album',
'memories.unlinkAlbum': 'Scollega', 'memories.unlinkAlbum': 'Scollega',
'memories.photos': 'foto', 'memories.photos': 'foto',
'memories.selectPhotos': 'Seleziona foto da Immich', 'memories.selectPhotos': 'Seleziona foto da Immich',
'memories.selectPhotosMultiple': 'Seleziona foto',
'memories.selectHint': 'Tocca le foto per selezionarle.', 'memories.selectHint': 'Tocca le foto per selezionarle.',
'memories.selected': 'selezionate', 'memories.selected': 'selezionate',
'memories.addSelected': 'Aggiungi {count} foto', 'memories.addSelected': 'Aggiungi {count} foto',
@@ -1573,6 +1685,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Segna come non letto', 'notifications.markUnread': 'Segna come non letto',
'notifications.delete': 'Elimina', 'notifications.delete': 'Elimina',
'notifications.system': 'Sistema', 'notifications.system': 'Sistema',
'notifications.synologySessionCleared.title': 'Synology Photos disconnesso',
'notifications.synologySessionCleared.text': 'Il server o l\'account è cambiato — vai alle Impostazioni per testare nuovamente la connessione.',
'memories.error.loadAlbums': 'Caricamento album non riuscito', 'memories.error.loadAlbums': 'Caricamento album non riuscito',
'memories.error.linkAlbum': 'Collegamento album non riuscito', 'memories.error.linkAlbum': 'Collegamento album non riuscito',
'memories.error.unlinkAlbum': 'Scollegamento album non riuscito', 'memories.error.unlinkAlbum': 'Scollegamento album non riuscito',
@@ -1654,7 +1768,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito', 'admin.notifications.adminWebhookPanel.testFailed': 'Invio webhook di test fallito',
'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL', 'admin.notifications.adminWebhookPanel.alwaysOnHint': 'Il webhook admin si attiva automaticamente quando è configurato un URL',
'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.', 'admin.notifications.adminNotificationsHint': 'Configura quali canali consegnano le notifiche admin (es. avvisi di versione). Il webhook si attiva automaticamente se è impostato un URL webhook admin.',
'admin.tabs.notifications': 'Notifications', 'admin.tabs.notifications': 'Notifiche',
'notifications.versionAvailable.title': 'Aggiornamento disponibile', 'notifications.versionAvailable.title': 'Aggiornamento disponibile',
'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.', 'notifications.versionAvailable.text': 'TREK {version} è ora disponibile.',
'notifications.versionAvailable.button': 'Visualizza dettagli', 'notifications.versionAvailable.button': 'Visualizza dettagli',
@@ -1693,6 +1807,306 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Hai una nuova notifica', 'notif.generic.text': 'Hai una nuova notifica',
'notif.dev.unknown_event.title': '[DEV] Evento sconosciuto', 'notif.dev.unknown_event.title': '[DEV] Evento sconosciuto',
'notif.dev.unknown_event.text': 'Il tipo di evento "{event}" non è registrato in EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'Il tipo di evento "{event}" non è registrato in EVENT_NOTIFICATION_CONFIG',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'proprio ora',
'common.hoursAgo': '{count}h fa',
'common.daysAgo': '{count}g fa',
'budget.linkedToReservation': 'Collegato a una prenotazione — modifica il nome lì',
'packing.saveAsTemplate': 'Salva come modello',
'packing.templateName': 'Nome del modello',
'packing.templateSaved': 'Lista bagagli salvata come modello',
'memories.notConnectedMultipleHint': 'Collega uno di questi fornitori di foto: {provider_names} nelle Impostazioni per poter aggiungere foto a questo viaggio.',
'memories.providerUrl': 'URL del server',
'memories.providerApiKey': 'Chiave API',
'memories.providerUsername': 'Nome utente',
'memories.providerPassword': 'Password',
'memories.saveError': 'Impossibile salvare le impostazioni di {provider_name}',
'memories.selectAlbumMultiple': 'Seleziona album',
'memories.selectPhotosMultiple': 'Seleziona foto',
'journey.title': 'Diario di viaggio',
'journey.subtitle': 'Segui i tuoi viaggi in tempo reale',
'journey.new': 'Nuovo diario',
'journey.create': 'Crea',
'journey.titlePlaceholder': 'Dove stai andando?',
'journey.empty': 'Nessun diario ancora',
'journey.emptyHint': 'Inizia a documentare il tuo prossimo viaggio',
'journey.deleted': 'Diario eliminato',
'journey.createError': 'Impossibile creare il diario',
'journey.deleteError': 'Impossibile eliminare il diario',
'journey.deleteConfirmTitle': 'Elimina',
'journey.deleteConfirmMessage': 'Eliminare "{title}"? Questa azione non può essere annullata.',
'journey.deleteConfirmGeneric': 'Sei sicuro di voler eliminare questo?',
'journey.notFound': 'Diario non trovato',
'journey.photos': 'Foto',
'journey.timelineEmpty': 'Nessuna tappa ancora',
'journey.timelineEmptyHint': 'Aggiungi un check-in o scrivi una voce di diario per iniziare',
'journey.status.draft': 'Bozza',
'journey.status.active': 'Attivo',
'journey.status.completed': 'Completato',
'journey.status.upcoming': 'In arrivo',
'journey.checkin.add': 'Check-in',
'journey.checkin.namePlaceholder': 'Nome del luogo',
'journey.checkin.notesPlaceholder': 'Note (facoltativo)',
'journey.checkin.save': 'Salva',
'journey.checkin.error': 'Impossibile salvare il check-in',
'journey.entry.add': 'Diario',
'journey.entry.edit': 'Modifica voce',
'journey.entry.titlePlaceholder': 'Titolo (facoltativo)',
'journey.entry.bodyPlaceholder': 'Cosa è successo oggi?',
'journey.entry.save': 'Salva',
'journey.entry.error': 'Impossibile salvare la voce',
'journey.photo.add': 'Foto',
'journey.photo.uploadError': 'Caricamento fallito',
'journey.share.share': 'Condividi',
'journey.share.public': 'Pubblico',
'journey.share.linkCopied': 'Link pubblico copiato',
'journey.share.disabled': 'Condivisione pubblica disattivata',
'journey.editor.titlePlaceholder': 'Dai un nome a questo momento...',
'journey.editor.bodyPlaceholder': 'Racconta la storia di questa giornata...',
'journey.editor.placePlaceholder': 'Luogo (facoltativo)',
'journey.editor.tagsPlaceholder': 'Tag: gioiello nascosto, miglior pasto, da rivisitare...',
'journey.visibility.private': 'Privato',
'journey.visibility.shared': 'Condiviso',
'journey.visibility.public': 'Pubblico',
'journey.emptyState.title': 'La tua storia inizia qui',
'journey.emptyState.subtitle': 'Fai un check-in o scrivi la tua prima voce di diario',
'journey.frontpage.subtitle': 'Trasforma i tuoi viaggi in storie indimenticabili',
'journey.frontpage.createJourney': 'Crea diario',
'journey.frontpage.activeJourney': 'Diario attivo',
'journey.frontpage.allJourneys': 'Tutti i diari',
'journey.frontpage.journeys': 'diari',
'journey.frontpage.createNew': 'Crea un nuovo diario',
'journey.frontpage.createNewSub': 'Scegli viaggi, scrivi storie, condividi le tue avventure',
'journey.frontpage.live': 'In diretta',
'journey.frontpage.synced': 'Sincronizzato',
'journey.frontpage.continueWriting': 'Continua a scrivere',
'journey.frontpage.updated': 'Aggiornato {time}',
'journey.frontpage.suggestionLabel': 'Viaggio appena terminato',
'journey.frontpage.suggestionText': 'Trasforma <strong>{title}</strong> in un diario di viaggio',
'journey.frontpage.dismiss': 'Ignora',
'journey.frontpage.journeyName': 'Nome del diario',
'journey.frontpage.namePlaceholder': 'es. Sud-est asiatico 2026',
'journey.frontpage.selectTrips': 'Seleziona viaggi',
'journey.frontpage.tripsSelected': 'viaggi selezionati',
'journey.frontpage.trips': 'viaggi',
'journey.frontpage.placesImported': 'luoghi saranno importati',
'journey.frontpage.places': 'luoghi',
'journey.detail.backToJourney': 'Torna al diario',
'journey.detail.syncedWithTrips': 'Sincronizzato con i viaggi',
'journey.detail.addEntry': 'Aggiungi voce',
'journey.detail.newEntry': 'Nuova voce',
'journey.detail.editEntry': 'Modifica voce',
'journey.detail.noEntries': 'Nessuna voce ancora',
'journey.detail.noEntriesHint': 'Aggiungi un viaggio per iniziare con voci precompilate',
'journey.detail.noPhotos': 'Nessuna foto ancora',
'journey.detail.noPhotosHint': 'Carica foto nelle voci o sfoglia la tua libreria Immich/Synology',
'journey.detail.journeyStats': 'Statistiche del diario',
'journey.detail.syncedTrips': 'Viaggi sincronizzati',
'journey.detail.noTripsLinked': 'Nessun viaggio collegato ancora',
'journey.detail.contributors': 'Contributori',
'journey.detail.readMore': 'Leggi di più',
'journey.detail.prosCons': 'Pro e contro',
'journey.stats.days': 'Giorni',
'journey.stats.cities': 'Città',
'journey.stats.entries': 'Voci',
'journey.stats.photos': 'Foto',
'journey.stats.places': 'Luoghi',
'journey.verdict.lovedIt': 'Adorato',
'journey.verdict.couldBeBetter': 'Potrebbe essere meglio',
'journey.synced.places': 'luoghi',
'journey.synced.synced': 'sincronizzato',
'journey.editor.uploadPhotos': 'Carica foto',
'journey.editor.fromGallery': 'Dalla galleria',
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
'journey.editor.writeStory': 'Scrivi la tua storia...',
'journey.editor.prosCons': 'Pro e contro',
'journey.editor.pros': 'Pro',
'journey.editor.cons': 'Contro',
'journey.editor.proPlaceholder': 'Qualcosa di fantastico...',
'journey.editor.conPlaceholder': 'Non così fantastico...',
'journey.editor.addAnother': 'Aggiungi un altro',
'journey.editor.date': 'Data',
'journey.editor.location': 'Luogo',
'journey.editor.searchLocation': 'Cerca luogo...',
'journey.editor.mood': 'Umore',
'journey.editor.weather': 'Meteo',
'journey.editor.photoFirst': '1°',
'journey.editor.makeFirst': 'Metti 1°',
'journey.mood.amazing': 'Fantastico',
'journey.mood.good': 'Buono',
'journey.mood.neutral': 'Neutro',
'journey.mood.rough': 'Difficile',
'journey.weather.sunny': 'Soleggiato',
'journey.weather.partly': 'Parzialmente nuvoloso',
'journey.weather.cloudy': 'Nuvoloso',
'journey.weather.rainy': 'Piovoso',
'journey.weather.stormy': 'Temporalesco',
'journey.weather.cold': 'Nevoso',
'journey.trips.linkTrip': 'Collega viaggio',
'journey.trips.searchTrip': 'Cerca viaggio',
'journey.trips.searchPlaceholder': 'Nome del viaggio o destinazione...',
'journey.trips.noTripsAvailable': 'Nessun viaggio disponibile',
'journey.trips.link': 'Collega',
'journey.trips.tripLinked': 'Viaggio collegato',
'journey.trips.linkFailed': 'Collegamento del viaggio fallito',
'journey.trips.addTrip': 'Aggiungi viaggio',
'journey.trips.unlinkTrip': 'Scollega viaggio',
'journey.trips.unlinkMessage': 'Scollegare "{title}"? Tutte le voci e le foto sincronizzate da questo viaggio saranno eliminate definitivamente. Questa azione non può essere annullata.',
'journey.trips.unlink': 'Scollega',
'journey.trips.tripUnlinked': 'Viaggio scollegato',
'journey.trips.unlinkFailed': 'Scollegamento del viaggio fallito',
'journey.trips.noTripsLinkedSettings': 'Nessun viaggio collegato',
'journey.contributors.invite': 'Invita contributore',
'journey.contributors.searchUser': 'Cerca utente',
'journey.contributors.searchPlaceholder': 'Nome utente o e-mail...',
'journey.contributors.noUsers': 'Nessun utente trovato',
'journey.contributors.role': 'Ruolo',
'journey.contributors.added': 'Contributore aggiunto',
'journey.contributors.addFailed': 'Impossibile aggiungere il contributore',
'journey.share.publicShare': 'Condivisione pubblica',
'journey.share.createLink': 'Crea link di condivisione',
'journey.share.linkCreated': 'Link di condivisione creato',
'journey.share.createFailed': 'Creazione del link fallita',
'journey.share.copy': 'Copia',
'journey.share.copied': 'Copiato!',
'journey.share.timeline': 'Cronologia',
'journey.share.gallery': 'Galleria',
'journey.share.map': 'Mappa',
'journey.share.removeLink': 'Rimuovi link di condivisione',
'journey.share.linkDeleted': 'Link di condivisione eliminato',
'journey.share.deleteFailed': 'Eliminazione fallita',
'journey.share.updateFailed': 'Aggiornamento fallito',
'journey.settings.title': 'Impostazioni del diario',
'journey.settings.coverImage': 'Immagine di copertina',
'journey.settings.changeCover': 'Cambia copertina',
'journey.settings.addCover': 'Aggiungi immagine di copertina',
'journey.settings.name': 'Nome',
'journey.settings.subtitle': 'Sottotitolo',
'journey.settings.subtitlePlaceholder': 'es. Thailandia, Vietnam e Cambogia',
'journey.settings.delete': 'Elimina',
'journey.settings.deleteJourney': 'Elimina diario',
'journey.settings.deleteMessage': 'Eliminare "{title}"? Tutte le voci e le foto andranno perse.',
'journey.settings.saved': 'Impostazioni salvate',
'journey.settings.saveFailed': 'Salvataggio fallito',
'journey.settings.coverUpdated': 'Copertina aggiornata',
'journey.settings.coverFailed': 'Caricamento fallito',
'journey.settings.failedToDelete': 'Eliminazione non riuscita',
'journey.entries.deleteTitle': 'Elimina voce',
'journey.photosUploaded': '{count} foto caricate',
'journey.photosAdded': '{count} foto aggiunte',
'journey.public.notFound': 'Non trovato',
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
'journey.public.readOnly': 'Sola lettura · Diario pubblico',
'journey.public.tagline': 'Travel Resource & Exploration Kit',
'journey.public.sharedVia': 'Condiviso tramite',
'journey.public.madeWith': 'Creato con',
'journey.pdf.journeyBook': 'Diario di viaggio',
'journey.pdf.madeWith': 'Creato con TREK',
'journey.pdf.day': 'Giorno',
'journey.pdf.theEnd': 'Fine',
'journey.pdf.saveAsPdf': 'Salva come PDF',
'journey.pdf.pages': 'pagine',
'dashboard.greeting.morning': 'Buongiorno,',
'dashboard.greeting.afternoon': 'Buon pomeriggio,',
'dashboard.greeting.evening': 'Buonasera,',
'dashboard.mobile.liveNow': 'In diretta',
'dashboard.mobile.tripProgress': 'Progresso del viaggio',
'dashboard.mobile.daysLeft': '{count} giorni rimanenti',
'dashboard.mobile.places': 'Luoghi',
'dashboard.mobile.buddies': 'Compagni',
'dashboard.mobile.newTrip': 'Nuovo viaggio',
'dashboard.mobile.currency': 'Valuta',
'dashboard.mobile.timezone': 'Fuso orario',
'dashboard.mobile.upcomingTrips': 'Viaggi in arrivo',
'dashboard.mobile.yourTrips': 'I tuoi viaggi',
'dashboard.mobile.trips': 'viaggi',
'dashboard.mobile.starts': 'Inizio',
'dashboard.mobile.duration': 'Durata',
'dashboard.mobile.day': 'giorno',
'dashboard.mobile.days': 'giorni',
'dashboard.mobile.ongoing': 'In corso',
'dashboard.mobile.startsToday': 'Inizia oggi',
'dashboard.mobile.tomorrow': 'Domani',
'dashboard.mobile.inDays': 'Tra {count} giorni',
'dashboard.mobile.inMonths': 'Tra {count} mesi',
'dashboard.mobile.completed': 'Completato',
'dashboard.mobile.currencyConverter': 'Convertitore di valuta',
'nav.profile': 'Profilo',
'nav.bottomSettings': 'Impostazioni',
'nav.bottomAdmin': 'Amministrazione',
'nav.bottomLogout': 'Disconnetti',
'nav.bottomAdminBadge': 'Admin',
'dayplan.mobile.addPlace': 'Aggiungi luogo',
'dayplan.mobile.searchPlaces': 'Cerca luoghi...',
'dayplan.mobile.allAssigned': 'Tutti i luoghi assegnati',
'dayplan.mobile.noMatch': 'Nessun risultato',
'dayplan.mobile.createNew': 'Crea nuovo luogo',
'admin.addons.catalog.journey.name': 'Diario di viaggio',
'admin.addons.catalog.journey.description': 'Tracciamento viaggi e diario con check-in, foto e storie quotidiane',
// OAuth scope groups
'oauth.scope.group.trips': 'Viaggi',
'oauth.scope.group.places': 'Luoghi',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Bagagli',
'oauth.scope.group.todos': 'Attività',
'oauth.scope.group.budget': 'Budget',
'oauth.scope.group.reservations': 'Prenotazioni',
'oauth.scope.group.collab': 'Collaborazione',
'oauth.scope.group.notifications': 'Notifiche',
'oauth.scope.group.vacay': 'Ferie',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Meteo',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Visualizza viaggi e itinerari',
'oauth.scope.trips:read.description': 'Leggi viaggi, giorni, note giornaliere e membri',
'oauth.scope.trips:write.label': 'Modifica viaggi e itinerari',
'oauth.scope.trips:write.description': 'Crea e aggiorna viaggi, giorni, note e gestisci membri',
'oauth.scope.trips:delete.label': 'Elimina viaggi',
'oauth.scope.trips:delete.description': 'Elimina definitivamente interi viaggi — questa azione è irreversibile',
'oauth.scope.trips:share.label': 'Gestisci link di condivisione',
'oauth.scope.trips:share.description': 'Crea, aggiorna e revoca link di condivisione pubblici per i viaggi',
'oauth.scope.places:read.label': 'Visualizza luoghi e dati mappa',
'oauth.scope.places:read.description': 'Leggi luoghi, assegnazioni giornaliere, tag e categorie',
'oauth.scope.places:write.label': 'Gestisci luoghi',
'oauth.scope.places:write.description': 'Crea, aggiorna ed elimina luoghi, assegnazioni e tag',
'oauth.scope.atlas:read.label': 'Visualizza Atlas',
'oauth.scope.atlas:read.description': 'Leggi paesi visitati, regioni e lista dei desideri',
'oauth.scope.atlas:write.label': 'Gestisci Atlas',
'oauth.scope.atlas:write.description': 'Segna paesi e regioni come visitati, gestisci la lista dei desideri',
'oauth.scope.packing:read.label': 'Visualizza liste bagagli',
'oauth.scope.packing:read.description': 'Leggi articoli, borse e assegnatari di categoria',
'oauth.scope.packing:write.label': 'Gestisci liste bagagli',
'oauth.scope.packing:write.description': 'Aggiungi, aggiorna, elimina, spunta e riordina articoli e borse',
'oauth.scope.todos:read.label': 'Visualizza liste attività',
'oauth.scope.todos:read.description': 'Leggi attività del viaggio e assegnatari di categoria',
'oauth.scope.todos:write.label': 'Gestisci liste attività',
'oauth.scope.todos:write.description': 'Crea, aggiorna, spunta, elimina e riordina attività',
'oauth.scope.budget:read.label': 'Visualizza budget',
'oauth.scope.budget:read.description': 'Leggi voci di budget e ripartizione delle spese',
'oauth.scope.budget:write.label': 'Gestisci budget',
'oauth.scope.budget:write.description': 'Crea, aggiorna ed elimina voci di budget',
'oauth.scope.reservations:read.label': 'Visualizza prenotazioni',
'oauth.scope.reservations:read.description': 'Leggi prenotazioni e dettagli alloggio',
'oauth.scope.reservations:write.label': 'Gestisci prenotazioni',
'oauth.scope.reservations:write.description': 'Crea, aggiorna, elimina e riordina prenotazioni',
'oauth.scope.collab:read.label': 'Visualizza collaborazione',
'oauth.scope.collab:read.description': 'Leggi note collaborative, sondaggi e messaggi',
'oauth.scope.collab:write.label': 'Gestisci collaborazione',
'oauth.scope.collab:write.description': 'Crea, aggiorna ed elimina note collaborative, sondaggi e messaggi',
'oauth.scope.notifications:read.label': 'Visualizza notifiche',
'oauth.scope.notifications:read.description': 'Leggi notifiche in-app e conteggi non letti',
'oauth.scope.notifications:write.label': 'Gestisci notifiche',
'oauth.scope.notifications:write.description': 'Segna notifiche come lette e rispondi',
'oauth.scope.vacay:read.label': 'Visualizza piani ferie',
'oauth.scope.vacay:read.description': 'Leggi dati di pianificazione ferie, voci e statistiche',
'oauth.scope.vacay:write.label': 'Gestisci piani ferie',
'oauth.scope.vacay:write.description': 'Crea e gestisci voci ferie, festività e piani del team',
'oauth.scope.geo:read.label': 'Mappe e geocodifica',
'oauth.scope.geo:read.description': 'Cerca luoghi, risolvi URL mappa e geocodifica inversa coordinate',
'oauth.scope.weather:read.label': 'Previsioni meteo',
'oauth.scope.weather:read.description': 'Ottieni previsioni meteo per luoghi e date del viaggio',
} }
export default it export default it
+436 -22
View File
@@ -8,6 +8,8 @@ const nl: Record<string, string> = {
'common.loading': 'Laden...', 'common.loading': 'Laden...',
'common.import': 'Importeren', 'common.import': 'Importeren',
'common.error': 'Fout', 'common.error': 'Fout',
'common.unknownError': 'Onbekende fout',
'common.tooManyAttempts': 'Te veel pogingen. Probeer het later opnieuw.',
'common.back': 'Terug', 'common.back': 'Terug',
'common.all': 'Alles', 'common.all': 'Alles',
'common.close': 'Sluiten', 'common.close': 'Sluiten',
@@ -27,6 +29,12 @@ const nl: Record<string, string> = {
'common.password': 'Wachtwoord', 'common.password': 'Wachtwoord',
'common.saving': 'Opslaan...', 'common.saving': 'Opslaan...',
'common.saved': 'Opgeslagen', 'common.saved': 'Opgeslagen',
'trips.memberRemoved': '{username} verwijderd',
'trips.memberRemoveError': 'Verwijderen mislukt',
'trips.memberAdded': '{username} toegevoegd',
'trips.memberAddError': 'Toevoegen mislukt',
'common.expand': 'Uitvouwen',
'common.collapse': 'Inklappen',
'trips.reminder': 'Herinnering', 'trips.reminder': 'Herinnering',
'trips.reminderNone': 'Geen', 'trips.reminderNone': 'Geen',
'trips.reminderDay': 'dag', 'trips.reminderDay': 'dag',
@@ -179,9 +187,6 @@ const nl: Record<string, string> = {
'admin.notifications.none': 'Uitgeschakeld', 'admin.notifications.none': 'Uitgeschakeld',
'admin.notifications.email': 'E-mail (SMTP)', 'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Meldingsgebeurtenissen',
'admin.notifications.eventsHint': 'Kies welke gebeurtenissen meldingen activeren voor alle gebruikers.',
'admin.notifications.configureFirst': 'Configureer eerst de SMTP- of webhook-instellingen hieronder en schakel dan de events in.',
'admin.notifications.save': 'Meldingsinstellingen opslaan', 'admin.notifications.save': 'Meldingsinstellingen opslaan',
'admin.notifications.saved': 'Meldingsinstellingen opgeslagen', 'admin.notifications.saved': 'Meldingsinstellingen opgeslagen',
'admin.notifications.testWebhook': 'Testwebhook verzenden', 'admin.notifications.testWebhook': 'Testwebhook verzenden',
@@ -228,6 +233,7 @@ const nl: Record<string, string> = {
'settings.mcp.endpoint': 'MCP-eindpunt', 'settings.mcp.endpoint': 'MCP-eindpunt',
'settings.mcp.clientConfig': 'Clientconfiguratie', 'settings.mcp.clientConfig': 'Clientconfiguratie',
'settings.mcp.clientConfigHint': 'Vervang <your_token> door een API-token uit de onderstaande lijst. Het pad naar npx moet mogelijk worden aangepast voor jouw systeem (bijv. C:\\PROGRA~1\\nodejs\\npx.cmd op Windows).', 'settings.mcp.clientConfigHint': 'Vervang <your_token> door een API-token uit de onderstaande lijst. Het pad naar npx moet mogelijk worden aangepast voor jouw systeem (bijv. C:\\PROGRA~1\\nodejs\\npx.cmd op Windows).',
'settings.mcp.clientConfigHintOAuth': 'Replace <your_client_id> and <your_client_secret> with the credentials shown in the OAuth 2.1 client you created above. mcp-remote will open your browser to complete the authorization the first time you connect. The path to npx may need to be adjusted for your system (e.g. C:\PROGRA~1\nodejs\npx.cmd on Windows).',
'settings.mcp.copy': 'Kopiëren', 'settings.mcp.copy': 'Kopiëren',
'settings.mcp.copied': 'Gekopieerd!', 'settings.mcp.copied': 'Gekopieerd!',
'settings.mcp.apiTokens': 'API-tokens', 'settings.mcp.apiTokens': 'API-tokens',
@@ -249,6 +255,48 @@ const nl: Record<string, string> = {
'settings.mcp.toast.createError': 'Token aanmaken mislukt', 'settings.mcp.toast.createError': 'Token aanmaken mislukt',
'settings.mcp.toast.deleted': 'Token verwijderd', 'settings.mcp.toast.deleted': 'Token verwijderd',
'settings.mcp.toast.deleteError': 'Token verwijderen mislukt', 'settings.mcp.toast.deleteError': 'Token verwijderen mislukt',
'settings.mcp.apiTokensDeprecated': 'API-tokens zijn verouderd en worden in een toekomstige versie verwijderd. Gebruik OAuth 2.1-clients in plaats daarvan.',
'settings.oauth.clients': 'OAuth 2.1-clients',
'settings.oauth.clientsHint': 'Registreer OAuth 2.1-clients zodat externe MCP-toepassingen (Claude Web, Cursor, enz.) verbinding kunnen maken zonder statische tokens.',
'settings.oauth.createClient': 'Nieuwe client',
'settings.oauth.noClients': 'Geen OAuth-clients geregistreerd.',
'settings.oauth.clientId': 'Client-ID',
'settings.oauth.clientSecret': 'Clientgeheim',
'settings.oauth.deleteClient': 'Client verwijderen',
'settings.oauth.deleteClientMessage': 'Deze client en alle actieve sessies worden permanent verwijderd. Elke toepassing die deze client gebruikt, verliest onmiddellijk de toegang.',
'settings.oauth.rotateSecret': 'Geheim vernieuwen',
'settings.oauth.rotateSecretMessage': 'Er wordt een nieuw clientgeheim gegenereerd en alle bestaande sessies worden direct ongeldig. Werk uw toepassing bij voordat u dit venster sluit.',
'settings.oauth.rotateSecretConfirm': 'Vernieuwen',
'settings.oauth.rotateSecretConfirming': 'Vernieuwen…',
'settings.oauth.rotateSecretDoneTitle': 'Nieuw geheim gegenereerd',
'settings.oauth.rotateSecretDoneWarning': 'Dit geheim wordt slechts eenmalig getoond. Kopieer het nu en werk uw toepassing bij — alle vorige sessies zijn ongeldig gemaakt.',
'settings.oauth.activeSessions': 'Actieve OAuth-sessies',
'settings.oauth.sessionScopes': 'Rechten',
'settings.oauth.sessionExpires': 'Verloopt',
'settings.oauth.revoke': 'Intrekken',
'settings.oauth.revokeSession': 'Sessie intrekken',
'settings.oauth.revokeSessionMessage': 'Dit trekt onmiddellijk de toegang voor deze OAuth-sessie in.',
'settings.oauth.modal.createTitle': 'OAuth-client registreren',
'settings.oauth.modal.presets': 'Snelle instellingen',
'settings.oauth.modal.clientName': 'Toepassingsnaam',
'settings.oauth.modal.clientNamePlaceholder': 'bijv. Claude Web, Mijn MCP-app',
'settings.oauth.modal.redirectUris': 'Redirect-URI\'s',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Eén URI per regel. HTTPS vereist (localhost uitgezonderd). Exacte overeenkomst vereist.',
'settings.oauth.modal.scopes': 'Toegestane rechten',
'settings.oauth.modal.scopesHint': 'list_trips en get_trip_summary zijn altijd beschikbaar — geen recht vereist. Ze helpen de AI trip-ID\'s te ontdekken.',
'settings.oauth.modal.selectAll': 'Alles selecteren',
'settings.oauth.modal.deselectAll': 'Alles deselecteren',
'settings.oauth.modal.creating': 'Registreren…',
'settings.oauth.modal.create': 'Client registreren',
'settings.oauth.modal.createdTitle': 'Client geregistreerd',
'settings.oauth.modal.createdWarning': 'Het clientgeheim wordt slechts eenmalig getoond. Kopieer het nu — het kan niet worden hersteld.',
'settings.oauth.toast.createError': 'OAuth-client kon niet worden geregistreerd',
'settings.oauth.toast.deleted': 'OAuth-client verwijderd',
'settings.oauth.toast.deleteError': 'OAuth-client kon niet worden verwijderd',
'settings.oauth.toast.revoked': 'Sessie ingetrokken',
'settings.oauth.toast.revokeError': 'Sessie kon niet worden ingetrokken',
'settings.oauth.toast.rotateError': 'Clientgeheim kon niet worden vernieuwd',
'settings.account': 'Account', 'settings.account': 'Account',
'settings.about': 'Over', 'settings.about': 'Over',
'settings.about.reportBug': 'Bug melden', 'settings.about.reportBug': 'Bug melden',
@@ -364,6 +412,10 @@ const nl: Record<string, string> = {
'login.mfaHint': 'Open Google Authenticator, Authy of een andere TOTP-app.', 'login.mfaHint': 'Open Google Authenticator, Authy of een andere TOTP-app.',
'login.mfaBack': '← Terug naar inloggen', 'login.mfaBack': '← Terug naar inloggen',
'login.mfaVerify': 'Verifiëren', 'login.mfaVerify': 'Verifiëren',
'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink',
'login.oidcFailed': 'OIDC-aanmelding mislukt',
'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.oidc.tokenFailed': 'Authenticatie mislukt.', 'login.oidc.tokenFailed': 'Authenticatie mislukt.',
'login.oidc.invalidState': 'Ongeldige sessie. Probeer het opnieuw.', 'login.oidc.invalidState': 'Ongeldige sessie. Probeer het opnieuw.',
'login.demoFailed': 'Demo-login mislukt', 'login.demoFailed': 'Demo-login mislukt',
@@ -450,6 +502,17 @@ const nl: Record<string, string> = {
'admin.tabs.settings': 'Instellingen', 'admin.tabs.settings': 'Instellingen',
'admin.allowRegistration': 'Registratie toestaan', 'admin.allowRegistration': 'Registratie toestaan',
'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren', 'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen', 'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen',
'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.', 'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.',
'admin.apiKeys': 'API-sleutels', 'admin.apiKeys': 'API-sleutels',
@@ -515,11 +578,11 @@ const nl: Record<string, string> = {
'admin.addons.catalog.budget.description': 'Houd uitgaven bij en plan je reisbudget', 'admin.addons.catalog.budget.description': 'Houd uitgaven bij en plan je reisbudget',
'admin.addons.catalog.documents.name': 'Documenten', 'admin.addons.catalog.documents.name': 'Documenten',
'admin.addons.catalog.documents.description': 'Bewaar en beheer reisdocumenten', 'admin.addons.catalog.documents.description': 'Bewaar en beheer reisdocumenten',
'admin.addons.catalog.vacay.name': 'Vacay', 'admin.addons.catalog.vacay.name': 'Vakantie',
'admin.addons.catalog.vacay.description': 'Persoonlijke vakantieplanner met kalenderweergave', 'admin.addons.catalog.vacay.description': 'Persoonlijke vakantieplanner met kalenderweergave',
'admin.addons.catalog.atlas.name': 'Atlas', 'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'Wereldkaart met bezochte landen en reisstatistieken', 'admin.addons.catalog.atlas.description': 'Wereldkaart met bezochte landen en reisstatistieken',
'admin.addons.catalog.collab.name': 'Collab', 'admin.addons.catalog.collab.name': 'Samenwerking',
'admin.addons.catalog.collab.description': 'Realtime notities, polls en chat voor het plannen van reizen', 'admin.addons.catalog.collab.description': 'Realtime notities, polls en chat voor het plannen van reizen',
'admin.addons.subtitleBefore': 'Schakel functies in of uit om je ', 'admin.addons.subtitleBefore': 'Schakel functies in of uit om je ',
'admin.addons.subtitleAfter': '-ervaring aan te passen.', 'admin.addons.subtitleAfter': '-ervaring aan te passen.',
@@ -547,9 +610,10 @@ const nl: Record<string, string> = {
'admin.weather.locationHint': 'Het weer is gebaseerd op de eerste plaats met coördinaten op elke dag. Als er geen plaats aan een dag is toegewezen, wordt een plaats uit de lijst als referentie gebruikt.', 'admin.weather.locationHint': 'Het weer is gebaseerd op de eerste plaats met coördinaten op elke dag. Als er geen plaats aan een dag is toegewezen, wordt een plaats uit de lijst als referentie gebruikt.',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'MCP-tokens', 'admin.tabs.mcpTokens': 'MCP-toegang',
'admin.mcpTokens.title': 'MCP-tokens', 'admin.mcpTokens.title': 'MCP-toegang',
'admin.mcpTokens.subtitle': 'API-tokens van alle gebruikers beheren', 'admin.mcpTokens.subtitle': 'OAuth-sessies en API-tokens van alle gebruikers beheren',
'admin.mcpTokens.sectionTitle': 'API-tokens',
'admin.mcpTokens.owner': 'Eigenaar', 'admin.mcpTokens.owner': 'Eigenaar',
'admin.mcpTokens.tokenName': 'Tokennaam', 'admin.mcpTokens.tokenName': 'Tokennaam',
'admin.mcpTokens.created': 'Aangemaakt', 'admin.mcpTokens.created': 'Aangemaakt',
@@ -561,6 +625,17 @@ const nl: Record<string, string> = {
'admin.mcpTokens.deleteSuccess': 'Token verwijderd', 'admin.mcpTokens.deleteSuccess': 'Token verwijderd',
'admin.mcpTokens.deleteError': 'Token kon niet worden verwijderd', 'admin.mcpTokens.deleteError': 'Token kon niet worden verwijderd',
'admin.mcpTokens.loadError': 'Tokens konden niet worden geladen', 'admin.mcpTokens.loadError': 'Tokens konden niet worden geladen',
'admin.oauthSessions.sectionTitle': 'OAuth-sessies',
'admin.oauthSessions.clientName': 'Client',
'admin.oauthSessions.owner': 'Eigenaar',
'admin.oauthSessions.scopes': 'Rechten',
'admin.oauthSessions.created': 'Aangemaakt',
'admin.oauthSessions.empty': 'Geen actieve OAuth-sessies',
'admin.oauthSessions.revokeTitle': 'Sessie intrekken',
'admin.oauthSessions.revokeMessage': 'Deze OAuth-sessie wordt onmiddellijk ingetrokken. De client verliest MCP-toegang.',
'admin.oauthSessions.revokeSuccess': 'Sessie ingetrokken',
'admin.oauthSessions.revokeError': 'Sessie kon niet worden ingetrokken',
'admin.oauthSessions.loadError': 'OAuth-sessies konden niet worden geladen',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -656,6 +731,8 @@ const nl: Record<string, string> = {
'vacay.companyHolidays': 'Bedrijfsvakanties', 'vacay.companyHolidays': 'Bedrijfsvakanties',
'vacay.companyHolidaysHint': 'Sta het markeren van bedrijfsbrede vakantiedagen toe', 'vacay.companyHolidaysHint': 'Sta het markeren van bedrijfsbrede vakantiedagen toe',
'vacay.companyHolidaysNoDeduct': 'Bedrijfsvakanties worden niet afgetrokken van vakantiedagen.', 'vacay.companyHolidaysNoDeduct': 'Bedrijfsvakanties worden niet afgetrokken van vakantiedagen.',
'vacay.weekStart': 'Week begint op',
'vacay.weekStartHint': 'Kies of de kalenderweek op maandag of zondag begint',
'vacay.carryOver': 'Overdracht', 'vacay.carryOver': 'Overdracht',
'vacay.carryOverHint': 'Draag resterende vakantiedagen automatisch over naar het volgende jaar', 'vacay.carryOverHint': 'Draag resterende vakantiedagen automatisch over naar het volgende jaar',
'vacay.sharing': 'Delen', 'vacay.sharing': 'Delen',
@@ -731,7 +808,7 @@ const nl: Record<string, string> = {
'atlas.placeVisited': 'Bezochte plaats', 'atlas.placeVisited': 'Bezochte plaats',
'atlas.placesVisited': 'Bezochte plaatsen', 'atlas.placesVisited': 'Bezochte plaatsen',
'atlas.statsTab': 'Statistieken', 'atlas.statsTab': 'Statistieken',
'atlas.bucketTab': 'Bucket List', 'atlas.bucketTab': 'Bucketlist',
'atlas.addBucket': 'Toevoegen aan bucket list', 'atlas.addBucket': 'Toevoegen aan bucket list',
'atlas.bucketNamePlaceholder': 'Plaats of bestemming...', 'atlas.bucketNamePlaceholder': 'Plaats of bestemming...',
'atlas.bucketNotesPlaceholder': 'Notities (optioneel)', 'atlas.bucketNotesPlaceholder': 'Notities (optioneel)',
@@ -847,7 +924,7 @@ const nl: Record<string, string> = {
'places.noCategory': 'Geen categorie', 'places.noCategory': 'Geen categorie',
'places.categoryNamePlaceholder': 'Categorienaam', 'places.categoryNamePlaceholder': 'Categorienaam',
'places.formTime': 'Tijd', 'places.formTime': 'Tijd',
'places.startTime': 'Start', 'places.startTime': 'Starttijd',
'places.endTime': 'Einde', 'places.endTime': 'Einde',
'places.endTimeBeforeStart': 'Eindtijd is vóór de starttijd', 'places.endTimeBeforeStart': 'Eindtijd is vóór de starttijd',
'places.timeCollision': 'Tijdoverlap met:', 'places.timeCollision': 'Tijdoverlap met:',
@@ -863,13 +940,14 @@ const nl: Record<string, string> = {
'places.nameRequired': 'Voer een naam in', 'places.nameRequired': 'Voer een naam in',
'places.saveError': 'Opslaan mislukt', 'places.saveError': 'Opslaan mislukt',
// Place Inspector // Place Inspector
'inspector.opened': 'Open', 'inspector.opened': 'Openingstijden',
'inspector.closed': 'Gesloten', 'inspector.closed': 'Gesloten',
'inspector.openingHours': 'Openingstijden', 'inspector.openingHours': 'Openingstijden',
'inspector.showHours': 'Openingstijden tonen', 'inspector.showHours': 'Openingstijden tonen',
'inspector.files': 'Bestanden', 'inspector.files': 'Bestanden',
'inspector.filesCount': '{count} bestanden', 'inspector.filesCount': '{count} bestanden',
'inspector.removeFromDay': 'Verwijderen van dag', 'inspector.removeFromDay': 'Verwijderen van dag',
'inspector.remove': 'Verwijderen',
'inspector.addToDay': 'Toevoegen aan dag', 'inspector.addToDay': 'Toevoegen aan dag',
'inspector.confirmedRes': 'Bevestigde reservering', 'inspector.confirmedRes': 'Bevestigde reservering',
'inspector.pendingRes': 'Reservering in behandeling', 'inspector.pendingRes': 'Reservering in behandeling',
@@ -909,8 +987,8 @@ const nl: Record<string, string> = {
'reservations.meta.trainNumber': 'Treinnr.', 'reservations.meta.trainNumber': 'Treinnr.',
'reservations.meta.platform': 'Perron', 'reservations.meta.platform': 'Perron',
'reservations.meta.seat': 'Stoel', 'reservations.meta.seat': 'Stoel',
'reservations.meta.checkIn': 'Check-in', 'reservations.meta.checkIn': 'Inchecken',
'reservations.meta.checkOut': 'Check-out', 'reservations.meta.checkOut': 'Uitchecken',
'reservations.meta.linkAccommodation': 'Accommodatie', 'reservations.meta.linkAccommodation': 'Accommodatie',
'reservations.meta.pickAccommodation': 'Koppel aan accommodatie', 'reservations.meta.pickAccommodation': 'Koppel aan accommodatie',
'reservations.meta.noAccommodation': 'Geen', 'reservations.meta.noAccommodation': 'Geen',
@@ -1010,18 +1088,23 @@ const nl: Record<string, string> = {
'budget.totalBudget': 'Totaal budget', 'budget.totalBudget': 'Totaal budget',
'budget.byCategory': 'Per categorie', 'budget.byCategory': 'Per categorie',
'budget.editTooltip': 'Klik om te bewerken', 'budget.editTooltip': 'Klik om te bewerken',
'budget.linkedToReservation': 'Gekoppeld aan een reservering — bewerk de naam daar',
'budget.confirm.deleteCategory': 'Weet je zeker dat je de categorie "{name}" met {count} invoeren wilt verwijderen?', 'budget.confirm.deleteCategory': 'Weet je zeker dat je de categorie "{name}" met {count} invoeren wilt verwijderen?',
'budget.deleteCategory': 'Categorie verwijderen', 'budget.deleteCategory': 'Categorie verwijderen',
'budget.perPerson': 'Per persoon', 'budget.perPerson': 'Per persoon',
'budget.paid': 'Betaald', 'budget.paid': 'Betaald',
'budget.open': 'Open', 'budget.open': 'Openstaand',
'budget.noMembers': 'Geen leden toegewezen', 'budget.noMembers': 'Geen leden toegewezen',
'budget.settlement': 'Afrekening', 'budget.settlement': 'Afrekening',
'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.', 'budget.settlementInfo': 'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
'budget.netBalances': 'Nettosaldi', 'budget.netBalances': 'Nettosaldi',
'budget.linkedToReservation': 'Gekoppeld aan een reservering — bewerk de naam daar',
// Files // Files
'files.title': 'Bestanden', 'files.title': 'Bestanden',
'files.pageTitle': 'Bestanden en documenten',
'files.subtitle': '{count} bestanden voor {trip}',
'files.downloadPdf': 'PDF downloaden',
'files.count': '{count} bestanden', 'files.count': '{count} bestanden',
'files.countSingular': '1 bestand', 'files.countSingular': '1 bestand',
'files.uploaded': '{count} geüpload', 'files.uploaded': '{count} geüpload',
@@ -1091,7 +1174,7 @@ const nl: Record<string, string> = {
'packing.addPlaceholder': 'Nieuw item toevoegen...', 'packing.addPlaceholder': 'Nieuw item toevoegen...',
'packing.categoryPlaceholder': 'Categorie...', 'packing.categoryPlaceholder': 'Categorie...',
'packing.filterAll': 'Alle', 'packing.filterAll': 'Alle',
'packing.filterOpen': 'Open', 'packing.filterOpen': 'Openstaand',
'packing.filterDone': 'Klaar', 'packing.filterDone': 'Klaar',
'packing.emptyTitle': 'Paklijst is leeg', 'packing.emptyTitle': 'Paklijst is leeg',
'packing.emptyHint': 'Voeg items toe of gebruik de suggesties', 'packing.emptyHint': 'Voeg items toe of gebruik de suggesties',
@@ -1108,7 +1191,9 @@ const nl: Record<string, string> = {
'packing.template': 'Sjabloon', 'packing.template': 'Sjabloon',
'packing.templateApplied': '{count} items toegevoegd vanuit sjabloon', 'packing.templateApplied': '{count} items toegevoegd vanuit sjabloon',
'packing.templateError': 'Fout bij toepassen van sjabloon', 'packing.templateError': 'Fout bij toepassen van sjabloon',
'packing.assignUser': 'Gebruiker toewijzen', 'packing.saveAsTemplate': 'Opslaan als sjabloon',
'packing.templateName': 'Sjabloonnaam',
'packing.templateSaved': 'Paklijst opgeslagen als sjabloon',
'packing.noMembers': 'Geen leden', 'packing.noMembers': 'Geen leden',
'packing.bags': 'Bagage', 'packing.bags': 'Bagage',
'packing.noBag': 'Niet toegewezen', 'packing.noBag': 'Niet toegewezen',
@@ -1265,6 +1350,13 @@ const nl: Record<string, string> = {
'backup.keep.forever': 'Voor altijd bewaren', 'backup.keep.forever': 'Voor altijd bewaren',
// Photos // Photos
'photos.title': 'Foto\'s',
'photos.subtitle': '{count} foto\'s voor {trip}',
'photos.dropHere': 'Foto\'s hier neerzetten...',
'photos.dropHereActive': 'Foto\'s hier neerzetten',
'photos.captionForAll': 'Bijschrift (voor alle)',
'photos.captionPlaceholder': 'Optioneel bijschrift...',
'photos.addCaption': 'Bijschrift toevoegen...',
'photos.allDays': 'Alle dagen', 'photos.allDays': 'Alle dagen',
'photos.noPhotos': 'Nog geen foto\'s', 'photos.noPhotos': 'Nog geen foto\'s',
'photos.uploadHint': 'Upload je reisfoto\'s', 'photos.uploadHint': 'Upload je reisfoto\'s',
@@ -1272,6 +1364,12 @@ const nl: Record<string, string> = {
'photos.linkPlace': 'Koppel plaats', 'photos.linkPlace': 'Koppel plaats',
'photos.noPlace': 'Geen plaats', 'photos.noPlace': 'Geen plaats',
'photos.uploadN': '{n} foto(\'s) uploaden', 'photos.uploadN': '{n} foto(\'s) uploaden',
'photos.linkDay': 'Dag koppelen',
'photos.noDay': 'Geen dag',
'photos.dayLabel': 'Dag {number}',
'photos.photoSelected': 'Foto geselecteerd',
'photos.photosSelected': "Foto's geselecteerd",
'photos.fileTypeHint': "JPG, PNG, WebP · max. 10 MB · tot 30 foto's",
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Back-up herstellen?', 'backup.restoreConfirmTitle': 'Back-up herstellen?',
@@ -1298,6 +1396,7 @@ const nl: Record<string, string> = {
'planner.routeCalculated': 'Route berekend', 'planner.routeCalculated': 'Route berekend',
'planner.routeCalcFailed': 'Route kon niet worden berekend', 'planner.routeCalcFailed': 'Route kon niet worden berekend',
'planner.routeError': 'Fout bij routeberekening', 'planner.routeError': 'Fout bij routeberekening',
'planner.icsExportFailed': 'ICS-export mislukt',
'planner.routeOptimized': 'Route geoptimaliseerd', 'planner.routeOptimized': 'Route geoptimaliseerd',
'planner.reservationUpdated': 'Reservering bijgewerkt', 'planner.reservationUpdated': 'Reservering bijgewerkt',
'planner.reservationAdded': 'Reservering toegevoegd', 'planner.reservationAdded': 'Reservering toegevoegd',
@@ -1373,8 +1472,8 @@ const nl: Record<string, string> = {
'day.hotelDayRange': 'Toepassen op dagen', 'day.hotelDayRange': 'Toepassen op dagen',
'day.noPlacesForHotel': 'Voeg eerst plaatsen toe aan je reis', 'day.noPlacesForHotel': 'Voeg eerst plaatsen toe aan je reis',
'day.allDays': 'Alle', 'day.allDays': 'Alle',
'day.checkIn': 'Check-in', 'day.checkIn': 'Inchecken',
'day.checkOut': 'Check-out', 'day.checkOut': 'Uitchecken',
'day.confirmation': 'Bevestiging', 'day.confirmation': 'Bevestiging',
'day.editAccommodation': 'Accommodatie bewerken', 'day.editAccommodation': 'Accommodatie bewerken',
'day.reservations': 'Reserveringen', 'day.reservations': 'Reserveringen',
@@ -1383,6 +1482,7 @@ const nl: Record<string, string> = {
'memories.title': 'Foto\'s', 'memories.title': 'Foto\'s',
'memories.notConnected': 'Immich niet verbonden', 'memories.notConnected': 'Immich niet verbonden',
'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.', 'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.',
'memories.notConnectedMultipleHint': 'Verbind een van deze fotoproviders: {provider_names} in Instellingen om foto\'s aan dit reisplan toe te voegen.',
'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.', 'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.',
'memories.noPhotos': 'Geen foto\'s gevonden', 'memories.noPhotos': 'Geen foto\'s gevonden',
'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.', 'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.',
@@ -1393,26 +1493,38 @@ const nl: Record<string, string> = {
'memories.reviewTitle': 'Je foto\'s bekijken', 'memories.reviewTitle': 'Je foto\'s bekijken',
'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.', 'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.',
'memories.shareCount': '{count} foto\'s delen', 'memories.shareCount': '{count} foto\'s delen',
'memories.immichUrl': 'Immich Server URL', 'memories.providerUrl': 'Server-URL',
'memories.immichApiKey': 'API-sleutel', 'memories.providerApiKey': 'API-sleutel',
'memories.providerUsername': 'Gebruikersnaam',
'memories.providerPassword': 'Wachtwoord',
'memories.providerOTP': 'MFA-code (indien ingeschakeld)',
'memories.skipSSLVerification': 'SSL-certificaatverificatie overslaan',
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo',
'memories.testConnection': 'Verbinding testen', 'memories.testConnection': 'Verbinding testen',
'memories.testFirst': 'Test eerst de verbinding', 'memories.testFirst': 'Test eerst de verbinding',
'memories.connected': 'Verbonden', 'memories.connected': 'Verbonden',
'memories.disconnected': 'Niet verbonden', 'memories.disconnected': 'Niet verbonden',
'memories.connectionSuccess': 'Verbonden met Immich', 'memories.connectionSuccess': 'Verbonden met Immich',
'memories.connectionError': 'Kon niet verbinden met Immich', 'memories.connectionError': 'Kon niet verbinden met Immich',
'memories.saved': 'Immich-instellingen opgeslagen', 'memories.saved': '{provider_name}-instellingen opgeslagen',
'memories.providerDisconnectedBanner': 'Je {provider_name}-verbinding is verbroken. Maak opnieuw verbinding in Instellingen om foto\'s te bekijken.',
'memories.saveError': '{provider_name}-instellingen konden niet worden opgeslagen',
'memories.saveRouteNotConfigured': 'Opslagroute is niet geconfigureerd voor deze provider',
'memories.testRouteNotConfigured': 'Testroute is niet geconfigureerd voor deze provider',
'memories.fillRequiredFields': 'Vul alle verplichte velden in',
'memories.oldest': 'Oudste eerst', 'memories.oldest': 'Oudste eerst',
'memories.newest': 'Nieuwste eerst', 'memories.newest': 'Nieuwste eerst',
'memories.allLocations': 'Alle locaties', 'memories.allLocations': 'Alle locaties',
'memories.addPhotos': 'Foto\'s toevoegen', 'memories.addPhotos': 'Foto\'s toevoegen',
'memories.linkAlbum': 'Album koppelen', 'memories.linkAlbum': 'Album koppelen',
'memories.selectAlbum': 'Immich-album selecteren', 'memories.selectAlbum': 'Immich-album selecteren',
'memories.selectAlbumMultiple': 'Album selecteren',
'memories.noAlbums': 'Geen albums gevonden', 'memories.noAlbums': 'Geen albums gevonden',
'memories.syncAlbum': 'Album synchroniseren', 'memories.syncAlbum': 'Album synchroniseren',
'memories.unlinkAlbum': 'Ontkoppelen', 'memories.unlinkAlbum': 'Ontkoppelen',
'memories.photos': 'fotos', 'memories.photos': 'fotos',
'memories.selectPhotos': 'Selecteer foto\'s uit Immich', 'memories.selectPhotos': 'Selecteer foto\'s uit Immich',
'memories.selectPhotosMultiple': 'Foto\'s selecteren',
'memories.selectHint': 'Tik op foto\'s om ze te selecteren.', 'memories.selectHint': 'Tik op foto\'s om ze te selecteren.',
'memories.selected': 'geselecteerd', 'memories.selected': 'geselecteerd',
'memories.addSelected': '{count} foto\'s toevoegen', 'memories.addSelected': '{count} foto\'s toevoegen',
@@ -1570,6 +1682,8 @@ const nl: Record<string, string> = {
'notifications.markUnread': 'Markeren als ongelezen', 'notifications.markUnread': 'Markeren als ongelezen',
'notifications.delete': 'Verwijderen', 'notifications.delete': 'Verwijderen',
'notifications.system': 'Systeem', 'notifications.system': 'Systeem',
'notifications.synologySessionCleared.title': 'Synology Photos verbroken',
'notifications.synologySessionCleared.text': 'Je server of account is gewijzigd — ga naar Instellingen om je verbinding opnieuw te testen.',
'memories.error.loadAlbums': 'Albums laden mislukt', 'memories.error.loadAlbums': 'Albums laden mislukt',
'memories.error.linkAlbum': 'Album koppelen mislukt', 'memories.error.linkAlbum': 'Album koppelen mislukt',
'memories.error.unlinkAlbum': 'Album ontkoppelen mislukt', 'memories.error.unlinkAlbum': 'Album ontkoppelen mislukt',
@@ -1599,7 +1713,7 @@ const nl: Record<string, string> = {
'todo.subtab.todo': 'Taken', 'todo.subtab.todo': 'Taken',
'todo.completed': 'voltooid', 'todo.completed': 'voltooid',
'todo.filter.all': 'Alles', 'todo.filter.all': 'Alles',
'todo.filter.open': 'Open', 'todo.filter.open': 'Openstaand',
'todo.filter.done': 'Klaar', 'todo.filter.done': 'Klaar',
'todo.uncategorized': 'Zonder categorie', 'todo.uncategorized': 'Zonder categorie',
'todo.namePlaceholder': 'Taaknaam', 'todo.namePlaceholder': 'Taaknaam',
@@ -1692,6 +1806,306 @@ const nl: Record<string, string> = {
'notif.generic.text': 'Je hebt een nieuwe melding', 'notif.generic.text': 'Je hebt een nieuwe melding',
'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis', 'notif.dev.unknown_event.title': '[DEV] Onbekende gebeurtenis',
'notif.dev.unknown_event.text': 'Gebeurtenistype "{event}" is niet geregistreerd in EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'Gebeurtenistype "{event}" is niet geregistreerd in EVENT_NOTIFICATION_CONFIG',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'zojuist',
'common.hoursAgo': '{count}u geleden',
'common.daysAgo': '{count}d geleden',
'budget.linkedToReservation': 'Gekoppeld aan een reservering — bewerk de naam daar',
'packing.saveAsTemplate': 'Opslaan als sjabloon',
'packing.templateName': 'Sjabloonnaam',
'packing.templateSaved': 'Paklijst opgeslagen als sjabloon',
'memories.notConnectedMultipleHint': 'Verbind een van deze foto-aanbieders: {provider_names} in Instellingen om foto\'s aan deze reis toe te voegen.',
'memories.providerUrl': 'Server-URL',
'memories.providerApiKey': 'API-sleutel',
'memories.providerUsername': 'Gebruikersnaam',
'memories.providerPassword': 'Wachtwoord',
'memories.saveError': 'Kon {provider_name}-instellingen niet opslaan',
'memories.selectAlbumMultiple': 'Selecteer album',
'memories.selectPhotosMultiple': 'Selecteer foto\'s',
'journey.title': 'Reisverslag',
'journey.subtitle': 'Leg je reizen vast terwijl je onderweg bent',
'journey.new': 'Nieuw reisverslag',
'journey.create': 'Aanmaken',
'journey.titlePlaceholder': 'Waar ga je naartoe?',
'journey.empty': 'Nog geen reisverslagen',
'journey.emptyHint': 'Begin met het vastleggen van je volgende reis',
'journey.deleted': 'Reisverslag verwijderd',
'journey.createError': 'Kon reisverslag niet aanmaken',
'journey.deleteError': 'Kon reisverslag niet verwijderen',
'journey.deleteConfirmTitle': 'Verwijderen',
'journey.deleteConfirmMessage': '"{title}" verwijderen? Dit kan niet ongedaan worden gemaakt.',
'journey.deleteConfirmGeneric': 'Weet je zeker dat je dit wilt verwijderen?',
'journey.notFound': 'Reisverslag niet gevonden',
'journey.photos': 'Foto\'s',
'journey.timelineEmpty': 'Nog geen stops',
'journey.timelineEmptyHint': 'Voeg een check-in toe of schrijf een dagboekvermelding om te beginnen',
'journey.status.draft': 'Concept',
'journey.status.active': 'Actief',
'journey.status.completed': 'Voltooid',
'journey.status.upcoming': 'Gepland',
'journey.checkin.add': 'Inchecken',
'journey.checkin.namePlaceholder': 'Locatienaam',
'journey.checkin.notesPlaceholder': 'Notities (optioneel)',
'journey.checkin.save': 'Opslaan',
'journey.checkin.error': 'Kon check-in niet opslaan',
'journey.entry.add': 'Dagboek',
'journey.entry.edit': 'Vermelding bewerken',
'journey.entry.titlePlaceholder': 'Titel (optioneel)',
'journey.entry.bodyPlaceholder': 'Wat is er vandaag gebeurd?',
'journey.entry.save': 'Opslaan',
'journey.entry.error': 'Kon vermelding niet opslaan',
'journey.photo.add': 'Foto',
'journey.photo.uploadError': 'Uploaden mislukt',
'journey.share.share': 'Delen',
'journey.share.public': 'Openbaar',
'journey.share.linkCopied': 'Openbare link gekopieerd',
'journey.share.disabled': 'Openbaar delen uitgeschakeld',
'journey.editor.titlePlaceholder': 'Geef dit moment een naam...',
'journey.editor.bodyPlaceholder': 'Vertel het verhaal van deze dag...',
'journey.editor.placePlaceholder': 'Locatie (optioneel)',
'journey.editor.tagsPlaceholder': 'Tags: verborgen parel, beste maaltijd, moet terugkomen...',
'journey.visibility.private': 'Privé',
'journey.visibility.shared': 'Gedeeld',
'journey.visibility.public': 'Openbaar',
'journey.emptyState.title': 'Je verhaal begint hier',
'journey.emptyState.subtitle': 'Check in op een plek of schrijf je eerste dagboekvermelding',
'journey.frontpage.subtitle': 'Maak van je reizen verhalen die je nooit vergeet',
'journey.frontpage.createJourney': 'Reisverslag aanmaken',
'journey.frontpage.activeJourney': 'Actief reisverslag',
'journey.frontpage.allJourneys': 'Alle reisverslagen',
'journey.frontpage.journeys': 'reisverslagen',
'journey.frontpage.createNew': 'Nieuw reisverslag aanmaken',
'journey.frontpage.createNewSub': 'Kies reizen, schrijf verhalen, deel je avonturen',
'journey.frontpage.live': 'Live',
'journey.frontpage.synced': 'Gesynchroniseerd',
'journey.frontpage.continueWriting': 'Verder schrijven',
'journey.frontpage.updated': 'Bijgewerkt {time}',
'journey.frontpage.suggestionLabel': 'Reis net afgelopen',
'journey.frontpage.suggestionText': 'Maak van <strong>{title}</strong> een reisverslag',
'journey.frontpage.dismiss': 'Sluiten',
'journey.frontpage.journeyName': 'Naam reisverslag',
'journey.frontpage.namePlaceholder': 'bijv. Zuidoost-Azië 2026',
'journey.frontpage.selectTrips': 'Selecteer reizen',
'journey.frontpage.tripsSelected': 'reizen geselecteerd',
'journey.frontpage.trips': 'reizen',
'journey.frontpage.placesImported': 'plaatsen worden geïmporteerd',
'journey.frontpage.places': 'plaatsen',
'journey.detail.backToJourney': 'Terug naar reisverslag',
'journey.detail.syncedWithTrips': 'Gesynchroniseerd met reizen',
'journey.detail.addEntry': 'Vermelding toevoegen',
'journey.detail.newEntry': 'Nieuwe vermelding',
'journey.detail.editEntry': 'Vermelding bewerken',
'journey.detail.noEntries': 'Nog geen vermeldingen',
'journey.detail.noEntriesHint': 'Voeg een reis toe om te beginnen met skeletvermeldingen',
'journey.detail.noPhotos': 'Nog geen foto\'s',
'journey.detail.noPhotosHint': 'Upload foto\'s naar vermeldingen of blader door je Immich/Synology-bibliotheek',
'journey.detail.journeyStats': 'Reisstatistieken',
'journey.detail.syncedTrips': 'Gesynchroniseerde reizen',
'journey.detail.noTripsLinked': 'Nog geen reizen gekoppeld',
'journey.detail.contributors': 'Bijdragers',
'journey.detail.readMore': 'Lees meer',
'journey.detail.prosCons': 'Voor- & nadelen',
'journey.stats.days': 'Dagen',
'journey.stats.cities': 'Steden',
'journey.stats.entries': 'Vermeldingen',
'journey.stats.photos': 'Foto\'s',
'journey.stats.places': 'Plaatsen',
'journey.verdict.lovedIt': 'Geweldig',
'journey.verdict.couldBeBetter': 'Kan beter',
'journey.synced.places': 'plaatsen',
'journey.synced.synced': 'gesynchroniseerd',
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
'journey.editor.fromGallery': 'Uit galerij',
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
'journey.editor.writeStory': 'Schrijf je verhaal...',
'journey.editor.prosCons': 'Voor- & nadelen',
'journey.editor.pros': 'Voordelen',
'journey.editor.cons': 'Nadelen',
'journey.editor.proPlaceholder': 'Iets geweldigs...',
'journey.editor.conPlaceholder': 'Niet zo geweldig...',
'journey.editor.addAnother': 'Nog een toevoegen',
'journey.editor.date': 'Datum',
'journey.editor.location': 'Locatie',
'journey.editor.searchLocation': 'Locatie zoeken...',
'journey.editor.mood': 'Stemming',
'journey.editor.weather': 'Weer',
'journey.editor.photoFirst': '1e',
'journey.editor.makeFirst': 'Maak 1e',
'journey.mood.amazing': 'Fantastisch',
'journey.mood.good': 'Goed',
'journey.mood.neutral': 'Neutraal',
'journey.mood.rough': 'Zwaar',
'journey.weather.sunny': 'Zonnig',
'journey.weather.partly': 'Halfbewolkt',
'journey.weather.cloudy': 'Bewolkt',
'journey.weather.rainy': 'Regenachtig',
'journey.weather.stormy': 'Stormachtig',
'journey.weather.cold': 'Sneeuw',
'journey.trips.linkTrip': 'Reis koppelen',
'journey.trips.searchTrip': 'Reis zoeken',
'journey.trips.searchPlaceholder': 'Reisnaam of bestemming...',
'journey.trips.noTripsAvailable': 'Geen reizen beschikbaar',
'journey.trips.link': 'Koppelen',
'journey.trips.tripLinked': 'Reis gekoppeld',
'journey.trips.linkFailed': 'Koppelen van reis mislukt',
'journey.trips.addTrip': 'Reis toevoegen',
'journey.trips.unlinkTrip': 'Reis ontkoppelen',
'journey.trips.unlinkMessage': '"{title}" ontkoppelen? Alle gesynchroniseerde vermeldingen en foto\'s van deze reis worden permanent verwijderd. Dit kan niet ongedaan worden gemaakt.',
'journey.trips.unlink': 'Ontkoppelen',
'journey.trips.tripUnlinked': 'Reis ontkoppeld',
'journey.trips.unlinkFailed': 'Ontkoppelen van reis mislukt',
'journey.trips.noTripsLinkedSettings': 'Geen reizen gekoppeld',
'journey.contributors.invite': 'Bijdrager uitnodigen',
'journey.contributors.searchUser': 'Gebruiker zoeken',
'journey.contributors.searchPlaceholder': 'Gebruikersnaam of e-mail...',
'journey.contributors.noUsers': 'Geen gebruikers gevonden',
'journey.contributors.role': 'Rol',
'journey.contributors.added': 'Bijdrager toegevoegd',
'journey.contributors.addFailed': 'Toevoegen van bijdrager mislukt',
'journey.share.publicShare': 'Openbaar delen',
'journey.share.createLink': 'Deellink aanmaken',
'journey.share.linkCreated': 'Deellink aangemaakt',
'journey.share.createFailed': 'Aanmaken van link mislukt',
'journey.share.copy': 'Kopiëren',
'journey.share.copied': 'Gekopieerd!',
'journey.share.timeline': 'Tijdlijn',
'journey.share.gallery': 'Galerij',
'journey.share.map': 'Kaart',
'journey.share.removeLink': 'Deellink verwijderen',
'journey.share.linkDeleted': 'Deellink verwijderd',
'journey.share.deleteFailed': 'Verwijderen mislukt',
'journey.share.updateFailed': 'Bijwerken mislukt',
'journey.settings.title': 'Reisverslaginstellingen',
'journey.settings.coverImage': 'Omslagfoto',
'journey.settings.changeCover': 'Omslag wijzigen',
'journey.settings.addCover': 'Omslagfoto toevoegen',
'journey.settings.name': 'Naam',
'journey.settings.subtitle': 'Ondertitel',
'journey.settings.subtitlePlaceholder': 'bijv. Thailand, Vietnam & Cambodja',
'journey.settings.delete': 'Verwijderen',
'journey.settings.deleteJourney': 'Reisverslag verwijderen',
'journey.settings.deleteMessage': '"{title}" verwijderen? Alle vermeldingen en foto\'s gaan verloren.',
'journey.settings.saved': 'Instellingen opgeslagen',
'journey.settings.saveFailed': 'Opslaan mislukt',
'journey.settings.coverUpdated': 'Omslag bijgewerkt',
'journey.settings.coverFailed': 'Uploaden mislukt',
'journey.settings.failedToDelete': 'Verwijderen mislukt',
'journey.entries.deleteTitle': 'Vermelding verwijderen',
'journey.photosUploaded': "{count} foto's geüpload",
'journey.photosAdded': "{count} foto's toegevoegd",
'journey.public.notFound': 'Niet gevonden',
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
'journey.public.readOnly': 'Alleen-lezen · Openbaar reisverslag',
'journey.public.tagline': 'Travel Resource & Exploration Kit',
'journey.public.sharedVia': 'Gedeeld via',
'journey.public.madeWith': 'Gemaakt met',
'journey.pdf.journeyBook': 'Reisboek',
'journey.pdf.madeWith': 'Gemaakt met TREK',
'journey.pdf.day': 'Dag',
'journey.pdf.theEnd': 'Einde',
'journey.pdf.saveAsPdf': 'Opslaan als PDF',
'journey.pdf.pages': 'pagina\'s',
'dashboard.greeting.morning': 'Goedemorgen,',
'dashboard.greeting.afternoon': 'Goedemiddag,',
'dashboard.greeting.evening': 'Goedenavond,',
'dashboard.mobile.liveNow': 'Nu live',
'dashboard.mobile.tripProgress': 'Reisvoortgang',
'dashboard.mobile.daysLeft': '{count} dagen over',
'dashboard.mobile.places': 'Plaatsen',
'dashboard.mobile.buddies': 'Reisgenoten',
'dashboard.mobile.newTrip': 'Nieuwe reis',
'dashboard.mobile.currency': 'Valuta',
'dashboard.mobile.timezone': 'Tijdzone',
'dashboard.mobile.upcomingTrips': 'Aankomende reizen',
'dashboard.mobile.yourTrips': 'Jouw reizen',
'dashboard.mobile.trips': 'reizen',
'dashboard.mobile.starts': 'Begint',
'dashboard.mobile.duration': 'Duur',
'dashboard.mobile.day': 'dag',
'dashboard.mobile.days': 'dagen',
'dashboard.mobile.ongoing': 'Bezig',
'dashboard.mobile.startsToday': 'Begint vandaag',
'dashboard.mobile.tomorrow': 'Morgen',
'dashboard.mobile.inDays': 'Over {count} dagen',
'dashboard.mobile.inMonths': 'Over {count} maanden',
'dashboard.mobile.completed': 'Voltooid',
'dashboard.mobile.currencyConverter': 'Valutaomrekener',
'nav.profile': 'Profiel',
'nav.bottomSettings': 'Instellingen',
'nav.bottomAdmin': 'Beheerdersinstellingen',
'nav.bottomLogout': 'Uitloggen',
'nav.bottomAdminBadge': 'Beheerder',
'dayplan.mobile.addPlace': 'Plaats toevoegen',
'dayplan.mobile.searchPlaces': 'Plaatsen zoeken...',
'dayplan.mobile.allAssigned': 'Alle plaatsen toegewezen',
'dayplan.mobile.noMatch': 'Geen resultaat',
'dayplan.mobile.createNew': 'Nieuwe plaats aanmaken',
'admin.addons.catalog.journey.name': 'Reisverslag',
'admin.addons.catalog.journey.description': 'Reistracking & reisdagboek met check-ins, foto\'s en dagelijkse verhalen',
// OAuth scope groups
'oauth.scope.group.trips': 'Reizen',
'oauth.scope.group.places': 'Plaatsen',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Paklijst',
'oauth.scope.group.todos': 'Taken',
'oauth.scope.group.budget': 'Budget',
'oauth.scope.group.reservations': 'Reserveringen',
'oauth.scope.group.collab': 'Samenwerking',
'oauth.scope.group.notifications': 'Meldingen',
'oauth.scope.group.vacay': 'Vakantie',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Weer',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Reizen en reisplannen bekijken',
'oauth.scope.trips:read.description': 'Reizen, dagen, notities en leden lezen',
'oauth.scope.trips:write.label': 'Reizen en reisplannen bewerken',
'oauth.scope.trips:write.description': 'Reizen, dagen en notities aanmaken, bijwerken en leden beheren',
'oauth.scope.trips:delete.label': 'Reizen verwijderen',
'oauth.scope.trips:delete.description': 'Hele reizen permanent verwijderen — deze actie is onomkeerbaar',
'oauth.scope.trips:share.label': 'Deellinks beheren',
'oauth.scope.trips:share.description': 'Publieke deellinks aanmaken, bijwerken en intrekken',
'oauth.scope.places:read.label': 'Plaatsen en kaartgegevens bekijken',
'oauth.scope.places:read.description': 'Plaatsen, dagtoewijzingen, tags en categorieën lezen',
'oauth.scope.places:write.label': 'Plaatsen beheren',
'oauth.scope.places:write.description': 'Plaatsen, toewijzingen en tags aanmaken, bijwerken en verwijderen',
'oauth.scope.atlas:read.label': 'Atlas bekijken',
'oauth.scope.atlas:read.description': 'Bezochte landen, regio\'s en bucketlist lezen',
'oauth.scope.atlas:write.label': 'Atlas beheren',
'oauth.scope.atlas:write.description': 'Landen en regio\'s markeren als bezocht, bucketlist beheren',
'oauth.scope.packing:read.label': 'Paklijsten bekijken',
'oauth.scope.packing:read.description': 'Pakartikelen, tassen en categorietoewijzingen lezen',
'oauth.scope.packing:write.label': 'Paklijsten beheren',
'oauth.scope.packing:write.description': 'Pakartikelen en tassen toevoegen, bijwerken, verwijderen, omschakelen en herordenen',
'oauth.scope.todos:read.label': 'Takenlijsten bekijken',
'oauth.scope.todos:read.description': 'Reistaakitems en categorietoewijzingen lezen',
'oauth.scope.todos:write.label': 'Takenlijsten beheren',
'oauth.scope.todos:write.description': 'Taakitems aanmaken, bijwerken, omschakelen, verwijderen en herordenen',
'oauth.scope.budget:read.label': 'Budget bekijken',
'oauth.scope.budget:read.description': 'Budgetitems en kostenspecificatie lezen',
'oauth.scope.budget:write.label': 'Budget beheren',
'oauth.scope.budget:write.description': 'Budgetitems aanmaken, bijwerken en verwijderen',
'oauth.scope.reservations:read.label': 'Reserveringen bekijken',
'oauth.scope.reservations:read.description': 'Reserveringen en accommodatiedetails lezen',
'oauth.scope.reservations:write.label': 'Reserveringen beheren',
'oauth.scope.reservations:write.description': 'Reserveringen aanmaken, bijwerken, verwijderen en herordenen',
'oauth.scope.collab:read.label': 'Samenwerking bekijken',
'oauth.scope.collab:read.description': 'Samenwerkingsnotities, polls en berichten lezen',
'oauth.scope.collab:write.label': 'Samenwerking beheren',
'oauth.scope.collab:write.description': 'Samenwerkingsnotities, polls en berichten aanmaken, bijwerken en verwijderen',
'oauth.scope.notifications:read.label': 'Meldingen bekijken',
'oauth.scope.notifications:read.description': 'In-app meldingen en ongelezen aantallen lezen',
'oauth.scope.notifications:write.label': 'Meldingen beheren',
'oauth.scope.notifications:write.description': 'Meldingen als gelezen markeren en erop reageren',
'oauth.scope.vacay:read.label': 'Vakantieplannen bekijken',
'oauth.scope.vacay:read.description': 'Vakantieplanningsgegevens, invoeren en statistieken lezen',
'oauth.scope.vacay:write.label': 'Vakantieplannen beheren',
'oauth.scope.vacay:write.description': 'Vakantie-invoeren, feestdagen en teamplannen aanmaken en beheren',
'oauth.scope.geo:read.label': 'Kaarten & geocodering',
'oauth.scope.geo:read.description': 'Locaties zoeken, kaart-URL\'s oplossen en coördinaten omgekeerd geocoderen',
'oauth.scope.weather:read.label': 'Weersverwachtingen',
'oauth.scope.weather:read.description': 'Weersverwachtingen ophalen voor reislocaties en -datums',
} }
export default nl export default nl
+434 -17
View File
@@ -7,6 +7,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'common.add': 'Dodaj', 'common.add': 'Dodaj',
'common.loading': 'Ładowanie...', 'common.loading': 'Ładowanie...',
'common.error': 'Błąd', 'common.error': 'Błąd',
'common.unknownError': 'Nieznany błąd',
'common.tooManyAttempts': 'Zbyt wiele prób. Spróbuj ponownie później.',
'common.back': 'Wstecz', 'common.back': 'Wstecz',
'common.all': 'Wszystko', 'common.all': 'Wszystko',
'common.close': 'Zamknij', 'common.close': 'Zamknij',
@@ -25,11 +27,17 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Hasło', 'common.password': 'Hasło',
'common.saving': 'Zapisywanie...', 'common.saving': 'Zapisywanie...',
'trips.memberRemoved': '{username} usunięty',
'trips.memberRemoveError': 'Nie udało się usunąć',
'trips.memberAdded': '{username} dodany',
'trips.memberAddError': 'Nie udało się dodać',
'common.expand': 'Rozwiń',
'common.collapse': 'Zwiń',
'common.update': 'Aktualizuj', 'common.update': 'Aktualizuj',
'common.change': 'Zmień', 'common.change': 'Zmień',
'common.uploading': 'Przesyłanie...', 'common.uploading': 'Przesyłanie...',
'common.backToPlanning': 'Powrót do planowania', 'common.backToPlanning': 'Powrót do planowania',
'common.reset': 'Reset', 'common.reset': 'Resetuj',
// Navbar // Navbar
'nav.trip': 'Podróż', 'nav.trip': 'Podróż',
@@ -198,6 +206,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.endpoint': 'Endpoint MCP', 'settings.mcp.endpoint': 'Endpoint MCP',
'settings.mcp.clientConfig': 'Konfiguracja klienta', 'settings.mcp.clientConfig': 'Konfiguracja klienta',
'settings.mcp.clientConfigHint': 'Zastąp <your_token> tokenem API z listy poniżej. Ścieżka do npx może wymagać dostosowania do Twojego systemu (np. C:\\PROGRA~1\\nodejs\\npx.cmd w systemie Windows).', 'settings.mcp.clientConfigHint': 'Zastąp <your_token> tokenem API z listy poniżej. Ścieżka do npx może wymagać dostosowania do Twojego systemu (np. C:\\PROGRA~1\\nodejs\\npx.cmd w systemie Windows).',
'settings.mcp.clientConfigHintOAuth': 'Zastąp <your_client_id> i <your_client_secret> danymi uwierzytelniającymi z klienta OAuth 2.1 utworzonego powyżej. mcp-remote otworzy przeglądarkę, aby dokończyć autoryzację przy pierwszym połączeniu. Ścieżka do npx może wymagać dostosowania do Twojego systemu (np. C:\\PROGRA~1\\nodejs\\npx.cmd w systemie Windows).',
'settings.mcp.copy': 'Kopiuj', 'settings.mcp.copy': 'Kopiuj',
'settings.mcp.copied': 'Skopiowano!', 'settings.mcp.copied': 'Skopiowano!',
'settings.mcp.apiTokens': 'Tokeny API', 'settings.mcp.apiTokens': 'Tokeny API',
@@ -219,6 +228,48 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.mcp.toast.createError': 'Nie udało się utworzyć tokenu', 'settings.mcp.toast.createError': 'Nie udało się utworzyć tokenu',
'settings.mcp.toast.deleted': 'Token został usunięty', 'settings.mcp.toast.deleted': 'Token został usunięty',
'settings.mcp.toast.deleteError': 'Nie udało się usunąć tokenu', 'settings.mcp.toast.deleteError': 'Nie udało się usunąć tokenu',
'settings.mcp.apiTokensDeprecated': 'Tokeny API są przestarzałe i zostaną usunięte w przyszłej wersji. Użyj zamiast tego klientów OAuth 2.1.',
'settings.oauth.clients': 'Klienci OAuth 2.1',
'settings.oauth.clientsHint': 'Zarejestruj klientów OAuth 2.1, aby zewnętrzne aplikacje MCP (Claude Web, Cursor itp.) mogły się łączyć bez statycznych tokenów.',
'settings.oauth.createClient': 'Nowy klient',
'settings.oauth.noClients': 'Brak zarejestrowanych klientów OAuth.',
'settings.oauth.clientId': 'ID klienta',
'settings.oauth.clientSecret': 'Sekret klienta',
'settings.oauth.deleteClient': 'Usuń klienta',
'settings.oauth.deleteClientMessage': 'Ten klient i wszystkie aktywne sesje zostaną trwale usunięte. Każda aplikacja, która go używa, natychmiast utraci dostęp.',
'settings.oauth.rotateSecret': 'Odnów sekret',
'settings.oauth.rotateSecretMessage': 'Zostanie wygenerowany nowy sekret klienta, a wszystkie istniejące sesje zostaną natychmiast unieważnione. Zaktualizuj aplikację przed zamknięciem tego okna.',
'settings.oauth.rotateSecretConfirm': 'Odnów',
'settings.oauth.rotateSecretConfirming': 'Odnawianie…',
'settings.oauth.rotateSecretDoneTitle': 'Wygenerowano nowy sekret',
'settings.oauth.rotateSecretDoneWarning': 'Ten sekret jest wyświetlany tylko raz. Skopiuj go teraz i zaktualizuj aplikację — wszystkie poprzednie sesje zostały unieważnione.',
'settings.oauth.activeSessions': 'Aktywne sesje OAuth',
'settings.oauth.sessionScopes': 'Uprawnienia',
'settings.oauth.sessionExpires': 'Wygasa',
'settings.oauth.revoke': 'Unieważnij',
'settings.oauth.revokeSession': 'Unieważnij sesję',
'settings.oauth.revokeSessionMessage': 'Spowoduje to natychmiastowe unieważnienie dostępu dla tej sesji OAuth.',
'settings.oauth.modal.createTitle': 'Zarejestruj klienta OAuth',
'settings.oauth.modal.presets': 'Szybkie ustawienia',
'settings.oauth.modal.clientName': 'Nazwa aplikacji',
'settings.oauth.modal.clientNamePlaceholder': 'np. Claude Web, Moja aplikacja MCP',
'settings.oauth.modal.redirectUris': 'URI przekierowania',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Jeden URI na linię. Wymagane HTTPS (localhost zwolniony). Wymagana dokładna zgodność.',
'settings.oauth.modal.scopes': 'Dozwolone uprawnienia',
'settings.oauth.modal.scopesHint': 'list_trips i get_trip_summary są zawsze dostępne — bez wymaganych uprawnień. Umożliwiają AI odkrycie potrzebnych ID podróży.',
'settings.oauth.modal.selectAll': 'Zaznacz wszystko',
'settings.oauth.modal.deselectAll': 'Odznacz wszystko',
'settings.oauth.modal.creating': 'Rejestrowanie…',
'settings.oauth.modal.create': 'Zarejestruj klienta',
'settings.oauth.modal.createdTitle': 'Klient zarejestrowany',
'settings.oauth.modal.createdWarning': 'Sekret klienta jest wyświetlany tylko raz. Skopiuj go teraz — nie można go odzyskać.',
'settings.oauth.toast.createError': 'Nie udało się zarejestrować klienta OAuth',
'settings.oauth.toast.deleted': 'Klient OAuth usunięty',
'settings.oauth.toast.deleteError': 'Nie udało się usunąć klienta OAuth',
'settings.oauth.toast.revoked': 'Sesja unieważniona',
'settings.oauth.toast.revokeError': 'Nie udało się unieważnić sesji',
'settings.oauth.toast.rotateError': 'Nie udało się odnowić sekretu klienta',
'settings.account': 'Konto', 'settings.account': 'Konto',
'settings.about': 'O aplikacji', 'settings.about': 'O aplikacji',
'settings.about.reportBug': 'Zgłoś błąd', 'settings.about.reportBug': 'Zgłoś błąd',
@@ -338,6 +389,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.mfaHint': 'Otwórz Google Authenticator, Authy lub inną aplikację TOTP.', 'login.mfaHint': 'Otwórz Google Authenticator, Authy lub inną aplikację TOTP.',
'login.mfaBack': '← Powrót do logowania', 'login.mfaBack': '← Powrót do logowania',
'login.mfaVerify': 'Weryfikuj', 'login.mfaVerify': 'Weryfikuj',
'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia',
'login.oidcFailed': 'Logowanie OIDC nie powiodło się',
'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
// Register // Register
'register.passwordMismatch': 'Hasła nie są identyczne', 'register.passwordMismatch': 'Hasła nie są identyczne',
@@ -418,6 +473,17 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Ustawienia', 'admin.tabs.settings': 'Ustawienia',
'admin.allowRegistration': 'Zezwól na rejestrację', 'admin.allowRegistration': 'Zezwól na rejestrację',
'admin.allowRegistrationHint': 'Nowi użytkownicy mogą się rejestrować samodzielnie', 'admin.allowRegistrationHint': 'Nowi użytkownicy mogą się rejestrować samodzielnie',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Wymagaj uwierzytelniania dwuskładnikowego (2FA)', 'admin.requireMfa': 'Wymagaj uwierzytelniania dwuskładnikowego (2FA)',
'admin.requireMfaHint': 'Użytkownicy bez 2FA muszą ukończyć konfigurację w Ustawieniach zanim zaczną korzystać z aplikacji.', 'admin.requireMfaHint': 'Użytkownicy bez 2FA muszą ukończyć konfigurację w Ustawieniach zanim zaczną korzystać z aplikacji.',
'admin.apiKeys': 'Klucze API', 'admin.apiKeys': 'Klucze API',
@@ -428,7 +494,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.recommended': 'Polecane', 'admin.recommended': 'Polecane',
'admin.weatherKey': 'Klucz OpenWeatherMap API', 'admin.weatherKey': 'Klucz OpenWeatherMap API',
'admin.weatherKeyHint': 'Do danych pogodowych. Uzyskaj go bezpłatnie na openweathermap.org', 'admin.weatherKeyHint': 'Do danych pogodowych. Uzyskaj go bezpłatnie na openweathermap.org',
'admin.validateKey': 'Test', 'admin.validateKey': 'Testuj',
'admin.keyValid': 'Połączono', 'admin.keyValid': 'Połączono',
'admin.keyInvalid': 'Niepoprawny', 'admin.keyInvalid': 'Niepoprawny',
'admin.keySaved': 'Klucze API zostały zapisane', 'admin.keySaved': 'Klucze API zostały zapisane',
@@ -484,7 +550,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.addons.catalog.vacay.description': 'Osobisty planer urlopu z widokiem kalendarza', 'admin.addons.catalog.vacay.description': 'Osobisty planer urlopu z widokiem kalendarza',
'admin.addons.catalog.atlas.name': 'Atlas', 'admin.addons.catalog.atlas.name': 'Atlas',
'admin.addons.catalog.atlas.description': 'Mapa świata z odwiedzonymi krajami i statystykami podróży', 'admin.addons.catalog.atlas.description': 'Mapa świata z odwiedzonymi krajami i statystykami podróży',
'admin.addons.catalog.collab.name': 'Collab', 'admin.addons.catalog.collab.name': 'Współpraca',
'admin.addons.catalog.collab.description': 'Notatki w czasie rzeczywistym, ankiety i czat do planowania podróży', 'admin.addons.catalog.collab.description': 'Notatki w czasie rzeczywistym, ankiety i czat do planowania podróży',
'admin.addons.catalog.memories.name': 'Zdjęcia (Immich)', 'admin.addons.catalog.memories.name': 'Zdjęcia (Immich)',
'admin.addons.catalog.memories.description': 'Udostępniaj zdjęcia z podróży za pośrednictwem swojej instancji Immich', 'admin.addons.catalog.memories.description': 'Udostępniaj zdjęcia z podróży za pośrednictwem swojej instancji Immich',
@@ -516,9 +582,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.weather.locationHint': 'Pogoda jest określana na podstawie pierwszego miejsca z przypisanymi współrzędnymi w danym dniu. Jeśli do dnia nie przypisano żadnego miejsca, jako punkt odniesienia używane jest dowolne miejsce z listy.', 'admin.weather.locationHint': 'Pogoda jest określana na podstawie pierwszego miejsca z przypisanymi współrzędnymi w danym dniu. Jeśli do dnia nie przypisano żadnego miejsca, jako punkt odniesienia używane jest dowolne miejsce z listy.',
// GitHub // GitHub
'admin.tabs.mcpTokens': 'Tokeny MCP', 'admin.tabs.mcpTokens': 'Dostęp MCP',
'admin.mcpTokens.title': 'Tokeny MCP', 'admin.mcpTokens.title': 'Dostęp MCP',
'admin.mcpTokens.subtitle': 'Zarządzaj tokenami API dla wszystkich użytkowników', 'admin.mcpTokens.subtitle': 'Zarządzaj sesjami OAuth i tokenami API dla wszystkich użytkowników',
'admin.mcpTokens.sectionTitle': 'Tokeny API',
'admin.mcpTokens.owner': 'Właściciel', 'admin.mcpTokens.owner': 'Właściciel',
'admin.mcpTokens.tokenName': 'Nazwa tokenu', 'admin.mcpTokens.tokenName': 'Nazwa tokenu',
'admin.mcpTokens.created': 'Utworzono', 'admin.mcpTokens.created': 'Utworzono',
@@ -530,6 +597,17 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.mcpTokens.deleteSuccess': 'Token został usunięty', 'admin.mcpTokens.deleteSuccess': 'Token został usunięty',
'admin.mcpTokens.deleteError': 'Nie udało się usunąć tokenu', 'admin.mcpTokens.deleteError': 'Nie udało się usunąć tokenu',
'admin.mcpTokens.loadError': 'Nie udało się załadować tokenów', 'admin.mcpTokens.loadError': 'Nie udało się załadować tokenów',
'admin.oauthSessions.sectionTitle': 'Sesje OAuth',
'admin.oauthSessions.clientName': 'Klient',
'admin.oauthSessions.owner': 'Właściciel',
'admin.oauthSessions.scopes': 'Uprawnienia',
'admin.oauthSessions.created': 'Utworzono',
'admin.oauthSessions.empty': 'Brak aktywnych sesji OAuth',
'admin.oauthSessions.revokeTitle': 'Unieważnij sesję',
'admin.oauthSessions.revokeMessage': 'Ta sesja OAuth zostanie natychmiast unieważniona. Klient straci dostęp do MCP.',
'admin.oauthSessions.revokeSuccess': 'Sesja unieważniona',
'admin.oauthSessions.revokeError': 'Nie udało się unieważnić sesji',
'admin.oauthSessions.loadError': 'Nie udało się załadować sesji OAuth',
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
'admin.audit.subtitle': 'Zdarzenia związane z bezpieczeństwem i administracją (kopie zapasowe, użytkownicy, MFA, ustawienia).', 'admin.audit.subtitle': 'Zdarzenia związane z bezpieczeństwem i administracją (kopie zapasowe, użytkownicy, MFA, ustawienia).',
@@ -597,7 +675,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'vacay.legend': 'Legenda', 'vacay.legend': 'Legenda',
'vacay.publicHoliday': 'Święto państwowe', 'vacay.publicHoliday': 'Święto państwowe',
'vacay.companyHoliday': 'Urlop firmowy', 'vacay.companyHoliday': 'Urlop firmowy',
'vacay.weekend': 'Weekend', 'vacay.weekend': 'Weekendowy',
'vacay.modeVacation': 'Urlop', 'vacay.modeVacation': 'Urlop',
'vacay.modeCompany': 'Urlop firmowy', 'vacay.modeCompany': 'Urlop firmowy',
'vacay.entitlement': 'Wymiar', 'vacay.entitlement': 'Wymiar',
@@ -626,6 +704,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'vacay.companyHolidays': 'Urlopy firmowe', 'vacay.companyHolidays': 'Urlopy firmowe',
'vacay.companyHolidaysHint': 'Pozwala oznaczać dni wolne od pracy w kalendarzu', 'vacay.companyHolidaysHint': 'Pozwala oznaczać dni wolne od pracy w kalendarzu',
'vacay.companyHolidaysNoDeduct': 'Urlopy firmowe nie są odejmowane od puli dni urlopowych.', 'vacay.companyHolidaysNoDeduct': 'Urlopy firmowe nie są odejmowane od puli dni urlopowych.',
'vacay.weekStart': 'Tydzień zaczyna się w',
'vacay.weekStartHint': 'Wybierz czy tydzień zaczyna się w poniedziałek czy niedzielę',
'vacay.carryOver': 'Przeniesienie na kolejny rok', 'vacay.carryOver': 'Przeniesienie na kolejny rok',
'vacay.carryOverHint': 'Automatycznie przenosi pozostałe dni urlopowe na kolejny rok', 'vacay.carryOverHint': 'Automatycznie przenosi pozostałe dni urlopowe na kolejny rok',
'vacay.sharing': 'Udostępnianie', 'vacay.sharing': 'Udostępnianie',
@@ -695,7 +775,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'atlas.lastTrip': 'Ostatnia podróż', 'atlas.lastTrip': 'Ostatnia podróż',
'atlas.nextTrip': 'Następna podróż', 'atlas.nextTrip': 'Następna podróż',
'atlas.daysLeft': 'dni do wyjazdu', 'atlas.daysLeft': 'dni do wyjazdu',
'atlas.streak': 'Streak', 'atlas.streak': 'Seria',
'atlas.years': 'lata', 'atlas.years': 'lata',
'atlas.yearInRow': 'rok z rzędu', 'atlas.yearInRow': 'rok z rzędu',
'atlas.yearsInRow': 'lat z rzędu', 'atlas.yearsInRow': 'lat z rzędu',
@@ -824,6 +904,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'inspector.files': 'Pliki', 'inspector.files': 'Pliki',
'inspector.filesCount': '{count} plików', 'inspector.filesCount': '{count} plików',
'inspector.removeFromDay': 'Usuń z dnia', 'inspector.removeFromDay': 'Usuń z dnia',
'inspector.remove': 'Usuń',
'inspector.addToDay': 'Dodaj do dnia', 'inspector.addToDay': 'Dodaj do dnia',
'inspector.confirmedRes': 'Potwierdzona rezerwacja', 'inspector.confirmedRes': 'Potwierdzona rezerwacja',
'inspector.pendingRes': 'Oczekująca rezerwacja', 'inspector.pendingRes': 'Oczekująca rezerwacja',
@@ -961,6 +1042,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'budget.totalBudget': 'Całkowity budżet', 'budget.totalBudget': 'Całkowity budżet',
'budget.byCategory': 'Według kategorii', 'budget.byCategory': 'Według kategorii',
'budget.editTooltip': 'Kliknij, aby edytować', 'budget.editTooltip': 'Kliknij, aby edytować',
'budget.linkedToReservation': 'Powiązano z rezerwacją — edytuj nazwę tam',
'budget.confirm.deleteCategory': 'Czy na pewno chcesz usunąć kategorię "{name}" z {count} wpisami?', 'budget.confirm.deleteCategory': 'Czy na pewno chcesz usunąć kategorię "{name}" z {count} wpisami?',
'budget.deleteCategory': 'Usuń kategorię', 'budget.deleteCategory': 'Usuń kategorię',
'budget.perPerson': 'Za osobę', 'budget.perPerson': 'Za osobę',
@@ -973,6 +1055,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
// Files // Files
'files.title': 'Pliki', 'files.title': 'Pliki',
'files.pageTitle': 'Pliki i dokumenty',
'files.subtitle': '{count} plików dla {trip}',
'files.downloadPdf': 'Pobierz PDF',
'files.count': '{count} plików', 'files.count': '{count} plików',
'files.countSingular': '1 plik', 'files.countSingular': '1 plik',
'files.uploaded': '{count} przesłanych', 'files.uploaded': '{count} przesłanych',
@@ -1051,7 +1136,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'packing.menuCheckAll': 'Zaznacz wszystko', 'packing.menuCheckAll': 'Zaznacz wszystko',
'packing.menuUncheckAll': 'Odznacz wszystko', 'packing.menuUncheckAll': 'Odznacz wszystko',
'packing.menuDeleteCat': 'Usuń kategorię', 'packing.menuDeleteCat': 'Usuń kategorię',
'packing.assignUser': 'Przypisz użytkownika', 'packing.saveAsTemplate': 'Zapisz jako szablon',
'packing.templateName': 'Nazwa szablonu',
'packing.templateSaved': 'Lista pakowania zapisana jako szablon',
'packing.noMembers': 'Brak członków podróży', 'packing.noMembers': 'Brak członków podróży',
'packing.addItem': 'Dodaj przedmiot', 'packing.addItem': 'Dodaj przedmiot',
'packing.addItemPlaceholder': 'Nazwa przedmiotu...', 'packing.addItemPlaceholder': 'Nazwa przedmiotu...',
@@ -1061,6 +1148,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'packing.template': 'Szablon', 'packing.template': 'Szablon',
'packing.templateApplied': '{count} przedmiotów dodanych z szablonu', 'packing.templateApplied': '{count} przedmiotów dodanych z szablonu',
'packing.templateError': 'Nie udało się zastosować szablonu', 'packing.templateError': 'Nie udało się zastosować szablonu',
'packing.saveAsTemplate': 'Zapisz jako szablon',
'packing.templateName': 'Nazwa szablonu',
'packing.templateSaved': 'Lista pakowania zapisana jako szablon',
'packing.bags': 'Torby', 'packing.bags': 'Torby',
'packing.noBag': 'Nieprzypisane', 'packing.noBag': 'Nieprzypisane',
'packing.totalWeight': 'Waga całkowita', 'packing.totalWeight': 'Waga całkowita',
@@ -1216,6 +1306,13 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'backup.keep.forever': 'Przechowuj na zawsze', 'backup.keep.forever': 'Przechowuj na zawsze',
// Photos // Photos
'photos.title': 'Zdjęcia',
'photos.subtitle': '{count} zdjęć dla {trip}',
'photos.dropHere': 'Przeciągnij zdjęcia tutaj...',
'photos.dropHereActive': 'Przeciągnij zdjęcia tutaj',
'photos.captionForAll': 'Podpis (dla wszystkich)',
'photos.captionPlaceholder': 'Opcjonalny podpis...',
'photos.addCaption': 'Dodaj podpis...',
'photos.allDays': 'Wszystkie dni', 'photos.allDays': 'Wszystkie dni',
'photos.noPhotos': 'Brak zdjęć', 'photos.noPhotos': 'Brak zdjęć',
'photos.uploadHint': 'Prześlij zdjęcia z podróży', 'photos.uploadHint': 'Prześlij zdjęcia z podróży',
@@ -1223,6 +1320,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'photos.linkPlace': 'Połącz z miejscem', 'photos.linkPlace': 'Połącz z miejscem',
'photos.noPlace': 'Brak miejsca', 'photos.noPlace': 'Brak miejsca',
'photos.uploadN': 'Prześlij {n} zdjęć', 'photos.uploadN': 'Prześlij {n} zdjęć',
'photos.linkDay': 'Połącz dzień',
'photos.noDay': 'Brak dnia',
'photos.dayLabel': 'Dzień {number}',
'photos.photoSelected': 'Zdjęcie wybrane',
'photos.photosSelected': 'Zdjęcia wybrane',
'photos.fileTypeHint': 'JPG, PNG, WebP · maks. 10 MB · do 30 zdjęć',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Przywrócić kopię zapasową?', 'backup.restoreConfirmTitle': 'Przywrócić kopię zapasową?',
@@ -1249,6 +1352,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'planner.routeCalculated': 'Trasa została obliczona', 'planner.routeCalculated': 'Trasa została obliczona',
'planner.routeCalcFailed': 'Nie udało się obliczyć trasy', 'planner.routeCalcFailed': 'Nie udało się obliczyć trasy',
'planner.routeError': 'Błąd obliczania trasy', 'planner.routeError': 'Błąd obliczania trasy',
'planner.icsExportFailed': 'Eksport ICS nie powiódł się',
'planner.routeOptimized': 'Trasa została zoptymalizowana', 'planner.routeOptimized': 'Trasa została zoptymalizowana',
'planner.reservationUpdated': 'Rezerwacja została zaktualizowana', 'planner.reservationUpdated': 'Rezerwacja została zaktualizowana',
'planner.reservationAdded': 'Rezerwacja została dodana', 'planner.reservationAdded': 'Rezerwacja została dodana',
@@ -1334,6 +1438,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.title': 'Zdjęcia', 'memories.title': 'Zdjęcia',
'memories.notConnected': 'Immich nie jest połączony', 'memories.notConnected': 'Immich nie jest połączony',
'memories.notConnectedHint': 'Połącz swoją instancję Immich w ustawieniach, aby przeglądać tutaj swoje zdjęcia z podróży.', 'memories.notConnectedHint': 'Połącz swoją instancję Immich w ustawieniach, aby przeglądać tutaj swoje zdjęcia z podróży.',
'memories.notConnectedMultipleHint': 'Połącz jednego z tych dostawców zdjęć: {provider_names} w Ustawieniach, aby móc dodawać zdjęcia do tej podróży.',
'memories.noDates': 'Dodaj daty do swojej podróży, aby załadować zdjęcia.', 'memories.noDates': 'Dodaj daty do swojej podróży, aby załadować zdjęcia.',
'memories.noPhotos': 'Nie znaleziono zdjęć', 'memories.noPhotos': 'Nie znaleziono zdjęć',
'memories.noPhotosHint': 'Nie znaleziono zdjęć w Immich dla tego zakresu dat podróży.', 'memories.noPhotosHint': 'Nie znaleziono zdjęć w Immich dla tego zakresu dat podróży.',
@@ -1344,16 +1449,24 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.reviewTitle': 'Przejrzyj swoje zdjęcia', 'memories.reviewTitle': 'Przejrzyj swoje zdjęcia',
'memories.reviewHint': 'Kliknij w zdjęcia, aby wykluczyć je z udostępnienia.', 'memories.reviewHint': 'Kliknij w zdjęcia, aby wykluczyć je z udostępnienia.',
'memories.shareCount': 'Udostępnij {count} zdjęć', 'memories.shareCount': 'Udostępnij {count} zdjęć',
'memories.immichUrl': 'URL serwera Immich', 'memories.providerUrl': 'URL serwera',
'memories.immichApiKey': 'Klucz API', 'memories.providerApiKey': 'Klucz API',
'memories.providerUsername': 'Nazwa użytkownika',
'memories.providerPassword': 'Hasło',
'memories.providerOTP': 'Kod MFA (jeśli włączony)',
'memories.skipSSLVerification': 'Pomiń weryfikację certyfikatu SSL',
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
'memories.testConnection': 'Test', 'memories.testConnection': 'Test',
'memories.connected': 'Połączono', 'memories.connected': 'Połączono',
'memories.disconnected': 'Nie połączono', 'memories.disconnected': 'Nie połączono',
'memories.connectionSuccess': 'Połączono z Immich', 'memories.connectionSuccess': 'Połączono z Immich',
'memories.connectionError': 'Nie udało się połączyć z Immich', 'memories.connectionError': 'Nie udało się połączyć z Immich',
'memories.saved': 'Ustawienia Immich zostały zapisane', 'memories.saved': 'Ustawienia {provider_name} zostały zapisane',
'memories.providerDisconnectedBanner': 'Połączenie z {provider_name} zostało utracone. Połącz ponownie w Ustawieniach, aby wyświetlać zdjęcia.',
'memories.saveError': 'Nie można zapisać ustawień {provider_name}',
'memories.addPhotos': 'Dodaj zdjęcia', 'memories.addPhotos': 'Dodaj zdjęcia',
'memories.selectPhotos': 'Wybierz zdjęcia z Immich', 'memories.selectPhotos': 'Wybierz zdjęcia z Immich',
'memories.selectPhotosMultiple': 'Wybierz zdjęcia',
'memories.selectHint': 'Dotknij zdjęć, aby je zaznaczyć.', 'memories.selectHint': 'Dotknij zdjęć, aby je zaznaczyć.',
'memories.selected': 'wybranych', 'memories.selected': 'wybranych',
'memories.addSelected': 'Dodaj {count} zdjęć', 'memories.addSelected': 'Dodaj {count} zdjęć',
@@ -1454,11 +1567,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.notifications.title': 'Powiadomienia', 'admin.notifications.title': 'Powiadomienia',
'admin.notifications.hint': 'Wybierz jeden kanał powiadomień.', 'admin.notifications.hint': 'Wybierz jeden kanał powiadomień.',
'admin.notifications.none': 'Wyłączone', 'admin.notifications.none': 'Wyłączone',
'admin.notifications.email': 'Email (SMTP)', 'admin.notifications.email': 'E-mail (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'Zdarzenia powiadomień',
'admin.notifications.eventsHint': 'Wybierz zdarzenia wyzwalające powiadomienia.',
'admin.notifications.configureFirst': 'Najpierw skonfiguruj ustawienia SMTP lub webhook.',
'admin.notifications.save': 'Zapisz ustawienia powiadomień', 'admin.notifications.save': 'Zapisz ustawienia powiadomień',
'admin.notifications.saved': 'Ustawienia powiadomień zapisane', 'admin.notifications.saved': 'Ustawienia powiadomień zapisane',
'admin.notifications.testWebhook': 'Wyślij testowy webhook', 'admin.notifications.testWebhook': 'Wyślij testowy webhook',
@@ -1483,7 +1593,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.webhookUrl.hint': 'Wprowadź adres URL webhooka Discord, Slack lub własnego, aby otrzymywać powiadomienia.', 'settings.webhookUrl.hint': 'Wprowadź adres URL webhooka Discord, Slack lub własnego, aby otrzymywać powiadomienia.',
'settings.webhookUrl.save': 'Zapisz', 'settings.webhookUrl.save': 'Zapisz',
'settings.webhookUrl.saved': 'URL webhooka zapisany', 'settings.webhookUrl.saved': 'URL webhooka zapisany',
'settings.webhookUrl.test': 'Test', 'settings.webhookUrl.test': 'Testuj',
'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie', 'settings.webhookUrl.testSuccess': 'Testowy webhook wysłany pomyślnie',
'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się', 'settings.webhookUrl.testFailed': 'Wysyłanie testowego webhooka nie powiodło się',
'settings.notificationPreferences.inapp': 'In-App', 'settings.notificationPreferences.inapp': 'In-App',
@@ -1509,9 +1619,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'inspector.trackStats': 'Statystyki trasy', 'inspector.trackStats': 'Statystyki trasy',
'budget.exportCsv': 'Eksportuj CSV', 'budget.exportCsv': 'Eksportuj CSV',
'budget.table.date': 'Data', 'budget.table.date': 'Data',
'budget.linkedToReservation': 'Powiązane z rezerwacją — edytuj nazwę tam',
'memories.testFirst': 'Najpierw przetestuj połączenie', 'memories.testFirst': 'Najpierw przetestuj połączenie',
'memories.linkAlbum': 'Połącz album', 'memories.linkAlbum': 'Połącz album',
'memories.selectAlbum': 'Wybierz album Immich', 'memories.selectAlbum': 'Wybierz album Immich',
'memories.selectAlbumMultiple': 'Wybierz album',
'memories.noAlbums': 'Nie znaleziono albumów', 'memories.noAlbums': 'Nie znaleziono albumów',
'memories.syncAlbum': 'Synchronizuj album', 'memories.syncAlbum': 'Synchronizuj album',
'memories.unlinkAlbum': 'Odłącz album', 'memories.unlinkAlbum': 'Odłącz album',
@@ -1598,6 +1710,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'notifications.markUnread': 'Oznacz jako nieprzeczytane', 'notifications.markUnread': 'Oznacz jako nieprzeczytane',
'notifications.delete': 'Usuń', 'notifications.delete': 'Usuń',
'notifications.system': 'System', 'notifications.system': 'System',
'notifications.synologySessionCleared.title': 'Synology Photos rozłączone',
'notifications.synologySessionCleared.text': 'Twój serwer lub konto zostało zmienione — przejdź do Ustawień, aby ponownie przetestować połączenie.',
'notifications.versionAvailable.title': 'Dostępna aktualizacja', 'notifications.versionAvailable.title': 'Dostępna aktualizacja',
'notifications.versionAvailable.text': 'TREK {version} jest już dostępny.', 'notifications.versionAvailable.text': 'TREK {version} jest już dostępny.',
'notifications.versionAvailable.button': 'Zobacz szczegóły', 'notifications.versionAvailable.button': 'Zobacz szczegóły',
@@ -1685,6 +1799,309 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'notif.generic.text': 'Masz nowe powiadomienie', 'notif.generic.text': 'Masz nowe powiadomienie',
'notif.dev.unknown_event.title': '[DEV] Nieznane zdarzenie', 'notif.dev.unknown_event.title': '[DEV] Nieznane zdarzenie',
'notif.dev.unknown_event.text': 'Typ zdarzenia "{event}" nie jest zarejestrowany w EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'Typ zdarzenia "{event}" nie jest zarejestrowany w EVENT_NOTIFICATION_CONFIG',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'przed chwilą',
'common.hoursAgo': '{count} godz. temu',
'common.daysAgo': '{count} dn. temu',
'budget.linkedToReservation': 'Powiązane z rezerwacją — edytuj nazwę tam',
'packing.saveAsTemplate': 'Zapisz jako szablon',
'packing.templateName': 'Nazwa szablonu',
'packing.templateSaved': 'Lista pakowania zapisana jako szablon',
'memories.notConnectedMultipleHint': 'Połącz jednego z tych dostawców zdjęć: {provider_names} w Ustawieniach, aby dodawać zdjęcia do tej podróży.',
'memories.providerUrl': 'Adres URL serwera',
'memories.providerApiKey': 'Klucz API',
'memories.providerUsername': 'Nazwa użytkownika',
'memories.providerPassword': 'Hasło',
'memories.saveError': 'Nie udało się zapisać ustawień {provider_name}',
'memories.saveRouteNotConfigured': 'Trasa zapisu nie jest skonfigurowana dla tego dostawcy',
'memories.testRouteNotConfigured': 'Trasa testowa nie jest skonfigurowana dla tego dostawcy',
'memories.fillRequiredFields': 'Proszę wypełnić wszystkie wymagane pola',
'memories.selectAlbumMultiple': 'Wybierz album',
'memories.selectPhotosMultiple': 'Wybierz zdjęcia',
'journey.title': 'Dziennik podróży',
'journey.subtitle': 'Dokumentuj swoje podróże na bieżąco',
'journey.new': 'Nowy dziennik podróży',
'journey.create': 'Utwórz',
'journey.titlePlaceholder': 'Dokąd jedziesz?',
'journey.empty': 'Brak dzienników podróży',
'journey.emptyHint': 'Zacznij dokumentować swoją następną podróż',
'journey.deleted': 'Dziennik podróży usunięty',
'journey.createError': 'Nie udało się utworzyć dziennika podróży',
'journey.deleteError': 'Nie udało się usunąć dziennika podróży',
'journey.deleteConfirmTitle': 'Usuń',
'journey.deleteConfirmMessage': 'Usunąć „{title}"? Tej operacji nie można cofnąć.',
'journey.deleteConfirmGeneric': 'Czy na pewno chcesz to usunąć?',
'journey.notFound': 'Nie znaleziono dziennika podróży',
'journey.photos': 'Zdjęcia',
'journey.timelineEmpty': 'Brak przystanków',
'journey.timelineEmptyHint': 'Dodaj zameldowanie lub napisz wpis w dzienniku, aby rozpocząć',
'journey.status.draft': 'Szkic',
'journey.status.active': 'Aktywny',
'journey.status.completed': 'Zakończony',
'journey.status.upcoming': 'Nadchodzący',
'journey.checkin.add': 'Zamelduj się',
'journey.checkin.namePlaceholder': 'Nazwa miejsca',
'journey.checkin.notesPlaceholder': 'Notatki (opcjonalnie)',
'journey.checkin.save': 'Zapisz',
'journey.checkin.error': 'Nie udało się zapisać zameldowania',
'journey.entry.add': 'Dziennik',
'journey.entry.edit': 'Edytuj wpis',
'journey.entry.titlePlaceholder': 'Tytuł (opcjonalnie)',
'journey.entry.bodyPlaceholder': 'Co się dziś wydarzyło?',
'journey.entry.save': 'Zapisz',
'journey.entry.error': 'Nie udało się zapisać wpisu',
'journey.photo.add': 'Zdjęcie',
'journey.photo.uploadError': 'Przesyłanie nie powiodło się',
'journey.share.share': 'Udostępnij',
'journey.share.public': 'Publiczny',
'journey.share.linkCopied': 'Publiczny link skopiowany',
'journey.share.disabled': 'Udostępnianie publiczne wyłączone',
'journey.editor.titlePlaceholder': 'Nadaj temu momentowi nazwę...',
'journey.editor.bodyPlaceholder': 'Opowiedz historię tego dnia...',
'journey.editor.placePlaceholder': 'Lokalizacja (opcjonalnie)',
'journey.editor.tagsPlaceholder': 'Tagi: ukryty skarb, najlepszy posiłek, warto wrócić...',
'journey.visibility.private': 'Prywatny',
'journey.visibility.shared': 'Udostępniony',
'journey.visibility.public': 'Publiczny',
'journey.emptyState.title': 'Twoja historia zaczyna się tutaj',
'journey.emptyState.subtitle': 'Zamelduj się w miejscu lub napisz swój pierwszy wpis w dzienniku',
'journey.frontpage.subtitle': 'Zamień swoje podróże w historie, których nigdy nie zapomnisz',
'journey.frontpage.createJourney': 'Utwórz dziennik podróży',
'journey.frontpage.activeJourney': 'Aktywny dziennik podróży',
'journey.frontpage.allJourneys': 'Wszystkie dzienniki podróży',
'journey.frontpage.journeys': 'dzienniki podróży',
'journey.frontpage.createNew': 'Utwórz nowy dziennik podróży',
'journey.frontpage.createNewSub': 'Wybierz podróże, pisz historie, dziel się przygodami',
'journey.frontpage.live': 'Na żywo',
'journey.frontpage.synced': 'Zsynchronizowany',
'journey.frontpage.continueWriting': 'Kontynuuj pisanie',
'journey.frontpage.updated': 'Zaktualizowano {time}',
'journey.frontpage.suggestionLabel': 'Podróż właśnie się zakończyła',
'journey.frontpage.suggestionText': 'Zamień <strong>{title}</strong> w dziennik podróży',
'journey.frontpage.dismiss': 'Odrzuć',
'journey.frontpage.journeyName': 'Nazwa dziennika podróży',
'journey.frontpage.namePlaceholder': 'np. Azja Południowo-Wschodnia 2026',
'journey.frontpage.selectTrips': 'Wybierz podróże',
'journey.frontpage.tripsSelected': 'podróży wybranych',
'journey.frontpage.trips': 'podróże',
'journey.frontpage.placesImported': 'miejsc zostanie zaimportowanych',
'journey.frontpage.places': 'miejsca',
'journey.detail.backToJourney': 'Powrót do dziennika podróży',
'journey.detail.syncedWithTrips': 'Zsynchronizowany z podróżami',
'journey.detail.addEntry': 'Dodaj wpis',
'journey.detail.newEntry': 'Nowy wpis',
'journey.detail.editEntry': 'Edytuj wpis',
'journey.detail.noEntries': 'Brak wpisów',
'journey.detail.noEntriesHint': 'Dodaj podróż, aby rozpocząć ze szkieletowymi wpisami',
'journey.detail.noPhotos': 'Brak zdjęć',
'journey.detail.noPhotosHint': 'Prześlij zdjęcia do wpisów lub przeglądaj bibliotekę Immich/Synology',
'journey.detail.journeyStats': 'Statystyki podróży',
'journey.detail.syncedTrips': 'Zsynchronizowane podróże',
'journey.detail.noTripsLinked': 'Brak powiązanych podróży',
'journey.detail.contributors': 'Współtwórcy',
'journey.detail.readMore': 'Czytaj dalej',
'journey.detail.prosCons': 'Zalety i wady',
'journey.stats.days': 'Dni',
'journey.stats.cities': 'Miasta',
'journey.stats.entries': 'Wpisy',
'journey.stats.photos': 'Zdjęcia',
'journey.stats.places': 'Miejsca',
'journey.verdict.lovedIt': 'Świetne',
'journey.verdict.couldBeBetter': 'Mogłoby być lepiej',
'journey.synced.places': 'miejsca',
'journey.synced.synced': 'zsynchronizowane',
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
'journey.editor.fromGallery': 'Z galerii',
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
'journey.editor.writeStory': 'Napisz swoją historię...',
'journey.editor.prosCons': 'Zalety i wady',
'journey.editor.pros': 'Zalety',
'journey.editor.cons': 'Wady',
'journey.editor.proPlaceholder': 'Coś świetnego...',
'journey.editor.conPlaceholder': 'Nie tak świetne...',
'journey.editor.addAnother': 'Dodaj kolejny',
'journey.editor.date': 'Data',
'journey.editor.location': 'Lokalizacja',
'journey.editor.searchLocation': 'Szukaj lokalizacji...',
'journey.editor.mood': 'Nastrój',
'journey.editor.weather': 'Pogoda',
'journey.editor.photoFirst': '1.',
'journey.editor.makeFirst': 'Ustaw jako 1.',
'journey.mood.amazing': 'Niesamowity',
'journey.mood.good': 'Dobry',
'journey.mood.neutral': 'Neutralny',
'journey.mood.rough': 'Ciężki',
'journey.weather.sunny': 'Słonecznie',
'journey.weather.partly': 'Częściowe zachmurzenie',
'journey.weather.cloudy': 'Pochmurno',
'journey.weather.rainy': 'Deszczowo',
'journey.weather.stormy': 'Burzowo',
'journey.weather.cold': 'Śnieżnie',
'journey.trips.linkTrip': 'Powiąż podróż',
'journey.trips.searchTrip': 'Szukaj podróży',
'journey.trips.searchPlaceholder': 'Nazwa podróży lub cel...',
'journey.trips.noTripsAvailable': 'Brak dostępnych podróży',
'journey.trips.link': 'Powiąż',
'journey.trips.tripLinked': 'Podróż powiązana',
'journey.trips.linkFailed': 'Powiązanie podróży nie powiodło się',
'journey.trips.addTrip': 'Dodaj podróż',
'journey.trips.unlinkTrip': 'Odłącz podróż',
'journey.trips.unlinkMessage': 'Odłączyć „{title}"? Wszystkie zsynchronizowane wpisy i zdjęcia z tej podróży zostaną trwale usunięte. Tej operacji nie można cofnąć.',
'journey.trips.unlink': 'Odłącz',
'journey.trips.tripUnlinked': 'Podróż odłączona',
'journey.trips.unlinkFailed': 'Odłączenie podróży nie powiodło się',
'journey.trips.noTripsLinkedSettings': 'Brak powiązanych podróży',
'journey.contributors.invite': 'Zaproś współtwórcę',
'journey.contributors.searchUser': 'Szukaj użytkownika',
'journey.contributors.searchPlaceholder': 'Nazwa użytkownika lub e-mail...',
'journey.contributors.noUsers': 'Nie znaleziono użytkowników',
'journey.contributors.role': 'Rola',
'journey.contributors.added': 'Współtwórca dodany',
'journey.contributors.addFailed': 'Dodawanie współtwórcy nie powiodło się',
'journey.share.publicShare': 'Udostępnianie publiczne',
'journey.share.createLink': 'Utwórz link udostępniania',
'journey.share.linkCreated': 'Link udostępniania utworzony',
'journey.share.createFailed': 'Tworzenie linku nie powiodło się',
'journey.share.copy': 'Kopiuj',
'journey.share.copied': 'Skopiowano!',
'journey.share.timeline': 'Oś czasu',
'journey.share.gallery': 'Galeria',
'journey.share.map': 'Mapa',
'journey.share.removeLink': 'Usuń link udostępniania',
'journey.share.linkDeleted': 'Link udostępniania usunięty',
'journey.share.deleteFailed': 'Usunięcie nie powiodło się',
'journey.share.updateFailed': 'Aktualizacja nie powiodła się',
'journey.settings.title': 'Ustawienia dziennika podróży',
'journey.settings.coverImage': 'Zdjęcie okładkowe',
'journey.settings.changeCover': 'Zmień okładkę',
'journey.settings.addCover': 'Dodaj zdjęcie okładkowe',
'journey.settings.name': 'Nazwa',
'journey.settings.subtitle': 'Podtytuł',
'journey.settings.subtitlePlaceholder': 'np. Tajlandia, Wietnam i Kambodża',
'journey.settings.delete': 'Usuń',
'journey.settings.deleteJourney': 'Usuń dziennik podróży',
'journey.settings.deleteMessage': 'Usunąć „{title}"? Wszystkie wpisy i zdjęcia zostaną utracone.',
'journey.settings.saved': 'Ustawienia zapisane',
'journey.settings.saveFailed': 'Zapisywanie nie powiodło się',
'journey.settings.coverUpdated': 'Okładka zaktualizowana',
'journey.settings.coverFailed': 'Przesyłanie nie powiodło się',
'journey.settings.failedToDelete': 'Nie udało się usunąć',
'journey.entries.deleteTitle': 'Usuń wpis',
'journey.photosUploaded': '{count} zdjęć przesłanych',
'journey.photosAdded': '{count} zdjęć dodanych',
'journey.public.notFound': 'Nie znaleziono',
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
'journey.public.readOnly': 'Tylko do odczytu · Publiczny dziennik podróży',
'journey.public.tagline': 'Travel Resource & Exploration Kit',
'journey.public.sharedVia': 'Udostępnione przez',
'journey.public.madeWith': 'Stworzone z',
'journey.pdf.journeyBook': 'Książka podróży',
'journey.pdf.madeWith': 'Stworzone z TREK',
'journey.pdf.day': 'Dzień',
'journey.pdf.theEnd': 'Koniec',
'journey.pdf.saveAsPdf': 'Zapisz jako PDF',
'journey.pdf.pages': 'stron',
'dashboard.greeting.morning': 'Dzień dobry,',
'dashboard.greeting.afternoon': 'Dzień dobry,',
'dashboard.greeting.evening': 'Dobry wieczór,',
'dashboard.mobile.liveNow': 'Na żywo',
'dashboard.mobile.tripProgress': 'Postęp podróży',
'dashboard.mobile.daysLeft': 'Pozostało {count} dni',
'dashboard.mobile.places': 'Miejsca',
'dashboard.mobile.buddies': 'Towarzysze',
'dashboard.mobile.newTrip': 'Nowa podróż',
'dashboard.mobile.currency': 'Waluta',
'dashboard.mobile.timezone': 'Strefa czasowa',
'dashboard.mobile.upcomingTrips': 'Nadchodzące podróże',
'dashboard.mobile.yourTrips': 'Twoje podróże',
'dashboard.mobile.trips': 'podróże',
'dashboard.mobile.starts': 'Początek',
'dashboard.mobile.duration': 'Czas trwania',
'dashboard.mobile.day': 'dzień',
'dashboard.mobile.days': 'dni',
'dashboard.mobile.ongoing': 'W trakcie',
'dashboard.mobile.startsToday': 'Zaczyna się dziś',
'dashboard.mobile.tomorrow': 'Jutro',
'dashboard.mobile.inDays': 'Za {count} dni',
'dashboard.mobile.inMonths': 'Za {count} miesięcy',
'dashboard.mobile.completed': 'Zakończone',
'dashboard.mobile.currencyConverter': 'Przelicznik walut',
'nav.profile': 'Profil',
'nav.bottomSettings': 'Ustawienia',
'nav.bottomAdmin': 'Ustawienia administratora',
'nav.bottomLogout': 'Wyloguj się',
'nav.bottomAdminBadge': 'Administrator',
'dayplan.mobile.addPlace': 'Dodaj miejsce',
'dayplan.mobile.searchPlaces': 'Szukaj miejsc...',
'dayplan.mobile.allAssigned': 'Wszystkie miejsca przypisane',
'dayplan.mobile.noMatch': 'Brak wyników',
'dayplan.mobile.createNew': 'Utwórz nowe miejsce',
'admin.addons.catalog.journey.name': 'Dziennik podróży',
'admin.addons.catalog.journey.description': 'Śledzenie podróży i dziennik z zameldowaniami, zdjęciami i codziennymi historiami',
// OAuth scope groups
'oauth.scope.group.trips': 'Podróże',
'oauth.scope.group.places': 'Miejsca',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Pakowanie',
'oauth.scope.group.todos': 'Zadania',
'oauth.scope.group.budget': 'Budżet',
'oauth.scope.group.reservations': 'Rezerwacje',
'oauth.scope.group.collab': 'Współpraca',
'oauth.scope.group.notifications': 'Powiadomienia',
'oauth.scope.group.vacay': 'Urlop',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Pogoda',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Przeglądaj podróże i itineraria',
'oauth.scope.trips:read.description': 'Odczytuj podróże, dni, notatki i członków',
'oauth.scope.trips:write.label': 'Edytuj podróże i itineraria',
'oauth.scope.trips:write.description': 'Twórz i aktualizuj podróże, dni, notatki oraz zarządzaj członkami',
'oauth.scope.trips:delete.label': 'Usuń podróże',
'oauth.scope.trips:delete.description': 'Trwale usuń całe podróże — ta akcja jest nieodwracalna',
'oauth.scope.trips:share.label': 'Zarządzaj linkami udostępniania',
'oauth.scope.trips:share.description': 'Twórz, aktualizuj i unieważniaj publiczne linki udostępniania',
'oauth.scope.places:read.label': 'Przeglądaj miejsca i dane mapy',
'oauth.scope.places:read.description': 'Odczytuj miejsca, przypisania dni, tagi i kategorie',
'oauth.scope.places:write.label': 'Zarządzaj miejscami',
'oauth.scope.places:write.description': 'Twórz, aktualizuj i usuń miejsca, przypisania i tagi',
'oauth.scope.atlas:read.label': 'Przeglądaj Atlas',
'oauth.scope.atlas:read.description': 'Odczytuj odwiedzone kraje, regiony i listę marzeń',
'oauth.scope.atlas:write.label': 'Zarządzaj Atlasem',
'oauth.scope.atlas:write.description': 'Oznaczaj kraje i regiony jako odwiedzone, zarządzaj listą marzeń',
'oauth.scope.packing:read.label': 'Przeglądaj listy pakowania',
'oauth.scope.packing:read.description': 'Odczytuj przedmioty, torby i przypisania kategorii',
'oauth.scope.packing:write.label': 'Zarządzaj listami pakowania',
'oauth.scope.packing:write.description': 'Dodawaj, aktualizuj, usuwaj, zaznaczaj i porządkuj przedmioty i torby',
'oauth.scope.todos:read.label': 'Przeglądaj listy zadań',
'oauth.scope.todos:read.description': 'Odczytuj zadania podróży i przypisania kategorii',
'oauth.scope.todos:write.label': 'Zarządzaj listami zadań',
'oauth.scope.todos:write.description': 'Twórz, aktualizuj, zaznaczaj, usuwaj i porządkuj zadania',
'oauth.scope.budget:read.label': 'Przeglądaj budżet',
'oauth.scope.budget:read.description': 'Odczytuj pozycje budżetu i zestawienie wydatków',
'oauth.scope.budget:write.label': 'Zarządzaj budżetem',
'oauth.scope.budget:write.description': 'Twórz, aktualizuj i usuń pozycje budżetu',
'oauth.scope.reservations:read.label': 'Przeglądaj rezerwacje',
'oauth.scope.reservations:read.description': 'Odczytuj rezerwacje i szczegóły zakwaterowania',
'oauth.scope.reservations:write.label': 'Zarządzaj rezerwacjami',
'oauth.scope.reservations:write.description': 'Twórz, aktualizuj, usuwaj i porządkuj rezerwacje',
'oauth.scope.collab:read.label': 'Przeglądaj współpracę',
'oauth.scope.collab:read.description': 'Odczytuj notatki, ankiety i wiadomości',
'oauth.scope.collab:write.label': 'Zarządzaj współpracą',
'oauth.scope.collab:write.description': 'Twórz, aktualizuj i usuń notatki, ankiety i wiadomości',
'oauth.scope.notifications:read.label': 'Przeglądaj powiadomienia',
'oauth.scope.notifications:read.description': 'Odczytuj powiadomienia i liczby nieprzeczytanych',
'oauth.scope.notifications:write.label': 'Zarządzaj powiadomieniami',
'oauth.scope.notifications:write.description': 'Oznaczaj powiadomienia jako przeczytane i odpowiadaj na nie',
'oauth.scope.vacay:read.label': 'Przeglądaj plany urlopowe',
'oauth.scope.vacay:read.description': 'Odczytuj dane planowania urlopu, wpisy i statystyki',
'oauth.scope.vacay:write.label': 'Zarządzaj planami urlopowymi',
'oauth.scope.vacay:write.description': 'Twórz i zarządzaj wpisami urlopowymi, świętami i planami zespołu',
'oauth.scope.geo:read.label': 'Mapy i geokodowanie',
'oauth.scope.geo:read.description': 'Wyszukuj miejsca, rozwiązuj adresy URL map i odwrotnie geokoduj współrzędne',
'oauth.scope.weather:read.label': 'Prognozy pogody',
'oauth.scope.weather:read.description': 'Pobieraj prognozy pogody dla miejsc i dat podróży',
} }
export default pl export default pl
+424 -10
View File
@@ -8,6 +8,8 @@ const ru: Record<string, string> = {
'common.loading': 'Загрузка...', 'common.loading': 'Загрузка...',
'common.import': 'Импорт', 'common.import': 'Импорт',
'common.error': 'Ошибка', 'common.error': 'Ошибка',
'common.unknownError': 'Неизвестная ошибка',
'common.tooManyAttempts': 'Слишком много попыток. Попробуйте позже.',
'common.back': 'Назад', 'common.back': 'Назад',
'common.all': 'Все', 'common.all': 'Все',
'common.close': 'Закрыть', 'common.close': 'Закрыть',
@@ -27,6 +29,12 @@ const ru: Record<string, string> = {
'common.password': 'Пароль', 'common.password': 'Пароль',
'common.saving': 'Сохранение...', 'common.saving': 'Сохранение...',
'common.saved': 'Сохранено', 'common.saved': 'Сохранено',
'common.expand': 'Развернуть',
'common.collapse': 'Свернуть',
'trips.memberRemoved': '{username} удалён',
'trips.memberRemoveError': 'Не удалось удалить',
'trips.memberAdded': '{username} добавлен',
'trips.memberAddError': 'Не удалось добавить',
'trips.reminder': 'Напоминание', 'trips.reminder': 'Напоминание',
'trips.reminderNone': 'Нет', 'trips.reminderNone': 'Нет',
'trips.reminderDay': 'день', 'trips.reminderDay': 'день',
@@ -179,9 +187,6 @@ const ru: Record<string, string> = {
'admin.notifications.none': 'Отключено', 'admin.notifications.none': 'Отключено',
'admin.notifications.email': 'Эл. почта (SMTP)', 'admin.notifications.email': 'Эл. почта (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': 'События уведомлений',
'admin.notifications.eventsHint': 'Выберите, какие события вызывают уведомления для всех пользователей.',
'admin.notifications.configureFirst': 'Сначала настройте SMTP или webhook ниже, затем включите события.',
'admin.notifications.save': 'Сохранить настройки уведомлений', 'admin.notifications.save': 'Сохранить настройки уведомлений',
'admin.notifications.saved': 'Настройки уведомлений сохранены', 'admin.notifications.saved': 'Настройки уведомлений сохранены',
'admin.notifications.testWebhook': 'Отправить тестовый вебхук', 'admin.notifications.testWebhook': 'Отправить тестовый вебхук',
@@ -228,6 +233,7 @@ const ru: Record<string, string> = {
'settings.mcp.endpoint': 'MCP-эндпоинт', 'settings.mcp.endpoint': 'MCP-эндпоинт',
'settings.mcp.clientConfig': 'Конфигурация клиента', 'settings.mcp.clientConfig': 'Конфигурация клиента',
'settings.mcp.clientConfigHint': 'Замените <your_token> на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).', 'settings.mcp.clientConfigHint': 'Замените <your_token> на API-токен из списка ниже. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).',
'settings.mcp.clientConfigHintOAuth': 'Замените <your_client_id> и <your_client_secret> на учётные данные из созданного выше клиента OAuth 2.1. При первом подключении mcp-remote откроет браузер для завершения авторизации. Путь к npx может потребовать настройки для вашей системы (например, C:\\PROGRA~1\\nodejs\\npx.cmd в Windows).',
'settings.mcp.copy': 'Копировать', 'settings.mcp.copy': 'Копировать',
'settings.mcp.copied': 'Скопировано!', 'settings.mcp.copied': 'Скопировано!',
'settings.mcp.apiTokens': 'API-токены', 'settings.mcp.apiTokens': 'API-токены',
@@ -249,6 +255,48 @@ const ru: Record<string, string> = {
'settings.mcp.toast.createError': 'Не удалось создать токен', 'settings.mcp.toast.createError': 'Не удалось создать токен',
'settings.mcp.toast.deleted': 'Токен удалён', 'settings.mcp.toast.deleted': 'Токен удалён',
'settings.mcp.toast.deleteError': 'Не удалось удалить токен', 'settings.mcp.toast.deleteError': 'Не удалось удалить токен',
'settings.mcp.apiTokensDeprecated': 'API-токены устарели и будут удалены в будущей версии. Пожалуйста, используйте клиенты OAuth 2.1.',
'settings.oauth.clients': 'Клиенты OAuth 2.1',
'settings.oauth.clientsHint': 'Зарегистрируйте клиенты OAuth 2.1, чтобы сторонние MCP-приложения (Claude Web, Cursor и др.) могли подключаться без статических токенов.',
'settings.oauth.createClient': 'Новый клиент',
'settings.oauth.noClients': 'Нет зарегистрированных клиентов OAuth.',
'settings.oauth.clientId': 'ID клиента',
'settings.oauth.clientSecret': 'Секрет клиента',
'settings.oauth.deleteClient': 'Удалить клиента',
'settings.oauth.deleteClientMessage': 'Этот клиент и все активные сессии будут удалены навсегда. Любое приложение, использующее его, немедленно потеряет доступ.',
'settings.oauth.rotateSecret': 'Обновить секрет',
'settings.oauth.rotateSecretMessage': 'Будет сгенерирован новый секрет клиента, а все существующие сессии будут немедленно аннулированы. Обновите приложение перед закрытием этого диалога.',
'settings.oauth.rotateSecretConfirm': 'Обновить',
'settings.oauth.rotateSecretConfirming': 'Обновление…',
'settings.oauth.rotateSecretDoneTitle': 'Новый секрет сгенерирован',
'settings.oauth.rotateSecretDoneWarning': 'Этот секрет отображается только один раз. Скопируйте его сейчас и обновите приложение — все предыдущие сессии были аннулированы.',
'settings.oauth.activeSessions': 'Активные сессии OAuth',
'settings.oauth.sessionScopes': 'Области доступа',
'settings.oauth.sessionExpires': 'Истекает',
'settings.oauth.revoke': 'Отозвать',
'settings.oauth.revokeSession': 'Отозвать сессию',
'settings.oauth.revokeSessionMessage': 'Это немедленно отзовёт доступ для данной сессии OAuth.',
'settings.oauth.modal.createTitle': 'Зарегистрировать клиент OAuth',
'settings.oauth.modal.presets': 'Быстрые настройки',
'settings.oauth.modal.clientName': 'Название приложения',
'settings.oauth.modal.clientNamePlaceholder': 'напр. Claude Web, Моё MCP-приложение',
'settings.oauth.modal.redirectUris': 'URI перенаправления',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': 'Один URI на строку. Требуется HTTPS (localhost исключён). Требуется точное совпадение.',
'settings.oauth.modal.scopes': 'Разрешённые области доступа',
'settings.oauth.modal.scopesHint': 'list_trips и get_trip_summary всегда доступны — область не требуется. Они помогают ИИ находить нужные ID поездок.',
'settings.oauth.modal.selectAll': 'Выбрать все',
'settings.oauth.modal.deselectAll': 'Снять выбор',
'settings.oauth.modal.creating': 'Регистрация…',
'settings.oauth.modal.create': 'Зарегистрировать клиента',
'settings.oauth.modal.createdTitle': 'Клиент зарегистрирован',
'settings.oauth.modal.createdWarning': 'Секрет клиента отображается только один раз. Скопируйте его сейчас — его нельзя будет восстановить.',
'settings.oauth.toast.createError': 'Не удалось зарегистрировать клиент OAuth',
'settings.oauth.toast.deleted': 'Клиент OAuth удалён',
'settings.oauth.toast.deleteError': 'Не удалось удалить клиент OAuth',
'settings.oauth.toast.revoked': 'Сессия отозвана',
'settings.oauth.toast.revokeError': 'Не удалось отозвать сессию',
'settings.oauth.toast.rotateError': 'Не удалось обновить секрет клиента',
'settings.account': 'Аккаунт', 'settings.account': 'Аккаунт',
'settings.about': 'О приложении', 'settings.about': 'О приложении',
'settings.about.reportBug': 'Сообщить об ошибке', 'settings.about.reportBug': 'Сообщить об ошибке',
@@ -364,6 +412,10 @@ const ru: Record<string, string> = {
'login.mfaHint': 'Откройте Google Authenticator, Authy или другое TOTP-приложение.', 'login.mfaHint': 'Откройте Google Authenticator, Authy или другое TOTP-приложение.',
'login.mfaBack': '← Назад к входу', 'login.mfaBack': '← Назад к входу',
'login.mfaVerify': 'Подтвердить', 'login.mfaVerify': 'Подтвердить',
'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение',
'login.oidcFailed': 'Ошибка входа через OIDC',
'login.usernameRequired': 'Имя пользователя обязательно',
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.oidc.tokenFailed': 'Аутентификация не удалась.', 'login.oidc.tokenFailed': 'Аутентификация не удалась.',
'login.oidc.invalidState': 'Недействительная сессия. Попробуйте снова.', 'login.oidc.invalidState': 'Недействительная сессия. Попробуйте снова.',
'login.demoFailed': 'Ошибка демо-входа', 'login.demoFailed': 'Ошибка демо-входа',
@@ -450,6 +502,17 @@ const ru: Record<string, string> = {
'admin.tabs.settings': 'Настройки', 'admin.tabs.settings': 'Настройки',
'admin.allowRegistration': 'Разрешить регистрацию', 'admin.allowRegistration': 'Разрешить регистрацию',
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно', 'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)', 'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)',
'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.', 'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.',
'admin.apiKeys': 'API-ключи', 'admin.apiKeys': 'API-ключи',
@@ -547,9 +610,10 @@ const ru: Record<string, string> = {
'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.', 'admin.weather.locationHint': 'Погода основана на первом месте с координатами в каждом дне. Если ни одно место не назначено на день, в качестве ориентира используется любое место из списка.',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'MCP-токены', 'admin.tabs.mcpTokens': 'MCP-доступ',
'admin.mcpTokens.title': 'MCP-токены', 'admin.mcpTokens.title': 'MCP-доступ',
'admin.mcpTokens.subtitle': 'Управление API-токенами всех пользователей', 'admin.mcpTokens.subtitle': 'Управление OAuth-сессиями и API-токенами всех пользователей',
'admin.mcpTokens.sectionTitle': 'API-токены',
'admin.mcpTokens.owner': 'Владелец', 'admin.mcpTokens.owner': 'Владелец',
'admin.mcpTokens.tokenName': 'Название токена', 'admin.mcpTokens.tokenName': 'Название токена',
'admin.mcpTokens.created': 'Создан', 'admin.mcpTokens.created': 'Создан',
@@ -561,6 +625,17 @@ const ru: Record<string, string> = {
'admin.mcpTokens.deleteSuccess': 'Токен удалён', 'admin.mcpTokens.deleteSuccess': 'Токен удалён',
'admin.mcpTokens.deleteError': 'Не удалось удалить токен', 'admin.mcpTokens.deleteError': 'Не удалось удалить токен',
'admin.mcpTokens.loadError': 'Не удалось загрузить токены', 'admin.mcpTokens.loadError': 'Не удалось загрузить токены',
'admin.oauthSessions.sectionTitle': 'OAuth-сессии',
'admin.oauthSessions.clientName': 'Клиент',
'admin.oauthSessions.owner': 'Владелец',
'admin.oauthSessions.scopes': 'Права доступа',
'admin.oauthSessions.created': 'Создано',
'admin.oauthSessions.empty': 'Нет активных OAuth-сессий',
'admin.oauthSessions.revokeTitle': 'Отозвать сессию',
'admin.oauthSessions.revokeMessage': 'Эта OAuth-сессия будет немедленно отозвана. Клиент потеряет доступ к MCP.',
'admin.oauthSessions.revokeSuccess': 'Сессия отозвана',
'admin.oauthSessions.revokeError': 'Не удалось отозвать сессию',
'admin.oauthSessions.loadError': 'Не удалось загрузить OAuth-сессии',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -656,6 +731,8 @@ const ru: Record<string, string> = {
'vacay.companyHolidays': 'Корпоративные выходные', 'vacay.companyHolidays': 'Корпоративные выходные',
'vacay.companyHolidaysHint': 'Разрешить отмечать корпоративные выходные дни', 'vacay.companyHolidaysHint': 'Разрешить отмечать корпоративные выходные дни',
'vacay.companyHolidaysNoDeduct': 'Корпоративные выходные не вычитаются из дней отпуска.', 'vacay.companyHolidaysNoDeduct': 'Корпоративные выходные не вычитаются из дней отпуска.',
'vacay.weekStart': 'Неделя начинается с',
'vacay.weekStartHint': 'Выберите, начинается ли неделя с понедельника или воскресенья',
'vacay.carryOver': 'Перенос', 'vacay.carryOver': 'Перенос',
'vacay.carryOverHint': 'Автоматически переносить оставшиеся дни отпуска на следующий год', 'vacay.carryOverHint': 'Автоматически переносить оставшиеся дни отпуска на следующий год',
'vacay.sharing': 'Общий доступ', 'vacay.sharing': 'Общий доступ',
@@ -870,6 +947,7 @@ const ru: Record<string, string> = {
'inspector.files': 'Файлы', 'inspector.files': 'Файлы',
'inspector.filesCount': '{count} файлов', 'inspector.filesCount': '{count} файлов',
'inspector.removeFromDay': 'Убрать из дня', 'inspector.removeFromDay': 'Убрать из дня',
'inspector.remove': 'Удалить',
'inspector.addToDay': 'Добавить в день', 'inspector.addToDay': 'Добавить в день',
'inspector.confirmedRes': 'Подтверждённое бронирование', 'inspector.confirmedRes': 'Подтверждённое бронирование',
'inspector.pendingRes': 'Ожидающее бронирование', 'inspector.pendingRes': 'Ожидающее бронирование',
@@ -1010,6 +1088,7 @@ const ru: Record<string, string> = {
'budget.totalBudget': 'Общий бюджет', 'budget.totalBudget': 'Общий бюджет',
'budget.byCategory': 'По категориям', 'budget.byCategory': 'По категориям',
'budget.editTooltip': 'Нажмите для редактирования', 'budget.editTooltip': 'Нажмите для редактирования',
'budget.linkedToReservation': 'Связано с бронированием — редактируйте название там',
'budget.confirm.deleteCategory': 'Вы уверены, что хотите удалить категорию «{name}» с {count} записями?', 'budget.confirm.deleteCategory': 'Вы уверены, что хотите удалить категорию «{name}» с {count} записями?',
'budget.deleteCategory': 'Удалить категорию', 'budget.deleteCategory': 'Удалить категорию',
'budget.perPerson': 'На человека', 'budget.perPerson': 'На человека',
@@ -1019,9 +1098,13 @@ const ru: Record<string, string> = {
'budget.settlement': 'Взаиморасчёт', 'budget.settlement': 'Взаиморасчёт',
'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.', 'budget.settlementInfo': 'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
'budget.netBalances': 'Чистые балансы', 'budget.netBalances': 'Чистые балансы',
'budget.linkedToReservation': 'Привязано к бронированию — измените название там',
// Files // Files
'files.title': 'Файлы', 'files.title': 'Файлы',
'files.pageTitle': 'Файлы и документы',
'files.subtitle': '{count} файлов для {trip}',
'files.downloadPdf': 'Скачать PDF',
'files.count': '{count} файлов', 'files.count': '{count} файлов',
'files.countSingular': '1 файл', 'files.countSingular': '1 файл',
'files.uploaded': '{count} загружено', 'files.uploaded': '{count} загружено',
@@ -1108,7 +1191,9 @@ const ru: Record<string, string> = {
'packing.template': 'Шаблон', 'packing.template': 'Шаблон',
'packing.templateApplied': '{count} вещей добавлено из шаблона', 'packing.templateApplied': '{count} вещей добавлено из шаблона',
'packing.templateError': 'Ошибка применения шаблона', 'packing.templateError': 'Ошибка применения шаблона',
'packing.assignUser': 'Назначить пользователя', 'packing.saveAsTemplate': 'Сохранить как шаблон',
'packing.templateName': 'Название шаблона',
'packing.templateSaved': 'Список вещей сохранён как шаблон',
'packing.noMembers': 'Нет участников', 'packing.noMembers': 'Нет участников',
'packing.bags': 'Багаж', 'packing.bags': 'Багаж',
'packing.noBag': 'Не назначено', 'packing.noBag': 'Не назначено',
@@ -1265,6 +1350,13 @@ const ru: Record<string, string> = {
'backup.keep.forever': 'Хранить вечно', 'backup.keep.forever': 'Хранить вечно',
// Photos // Photos
'photos.title': 'Фотографии',
'photos.subtitle': '{count} фото для {trip}',
'photos.dropHere': 'Перетащите фото сюда...',
'photos.dropHereActive': 'Перетащите фото сюда',
'photos.captionForAll': 'Подпись (для всех)',
'photos.captionPlaceholder': 'Необязательная подпись...',
'photos.addCaption': 'Добавить подпись...',
'photos.allDays': 'Все дни', 'photos.allDays': 'Все дни',
'photos.noPhotos': 'Фото пока нет', 'photos.noPhotos': 'Фото пока нет',
'photos.uploadHint': 'Загрузите фото из путешествия', 'photos.uploadHint': 'Загрузите фото из путешествия',
@@ -1272,6 +1364,12 @@ const ru: Record<string, string> = {
'photos.linkPlace': 'Привязать место', 'photos.linkPlace': 'Привязать место',
'photos.noPlace': 'Без места', 'photos.noPlace': 'Без места',
'photos.uploadN': '{n} фото загружено', 'photos.uploadN': '{n} фото загружено',
'photos.linkDay': 'Связать день',
'photos.noDay': 'Нет дня',
'photos.dayLabel': 'День {number}',
'photos.photoSelected': 'Фото выбрано',
'photos.photosSelected': 'Фото выбраны',
'photos.fileTypeHint': 'JPG, PNG, WebP · макс. 10 МБ · до 30 фото',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': 'Восстановить копию?', 'backup.restoreConfirmTitle': 'Восстановить копию?',
@@ -1298,6 +1396,7 @@ const ru: Record<string, string> = {
'planner.routeCalculated': 'Маршрут рассчитан', 'planner.routeCalculated': 'Маршрут рассчитан',
'planner.routeCalcFailed': 'Не удалось рассчитать маршрут', 'planner.routeCalcFailed': 'Не удалось рассчитать маршрут',
'planner.routeError': 'Ошибка расчёта маршрута', 'planner.routeError': 'Ошибка расчёта маршрута',
'planner.icsExportFailed': 'Не удалось экспортировать ICS',
'planner.routeOptimized': 'Маршрут оптимизирован', 'planner.routeOptimized': 'Маршрут оптимизирован',
'planner.reservationUpdated': 'Бронирование обновлено', 'planner.reservationUpdated': 'Бронирование обновлено',
'planner.reservationAdded': 'Бронирование добавлено', 'planner.reservationAdded': 'Бронирование добавлено',
@@ -1383,6 +1482,7 @@ const ru: Record<string, string> = {
'memories.title': 'Фото', 'memories.title': 'Фото',
'memories.notConnected': 'Immich не подключён', 'memories.notConnected': 'Immich не подключён',
'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.', 'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.',
'memories.notConnectedMultipleHint': 'Подключите одного из этих фотопровайдеров: {provider_names} в Настройках, чтобы добавлять фотографии к этому путешествию.',
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.', 'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
'memories.noPhotos': 'Фотографии не найдены', 'memories.noPhotos': 'Фотографии не найдены',
'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.', 'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.',
@@ -1393,26 +1493,35 @@ const ru: Record<string, string> = {
'memories.reviewTitle': 'Проверьте ваши фото', 'memories.reviewTitle': 'Проверьте ваши фото',
'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.', 'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.',
'memories.shareCount': 'Поделиться ({count} фото)', 'memories.shareCount': 'Поделиться ({count} фото)',
'memories.immichUrl': 'URL сервера Immich', 'memories.providerUrl': 'URL сервера',
'memories.immichApiKey': 'API-ключ', 'memories.providerApiKey': 'API-ключ',
'memories.providerUsername': 'Имя пользователя',
'memories.providerPassword': 'Пароль',
'memories.providerOTP': 'Код MFA (если включён)',
'memories.skipSSLVerification': 'Пропустить проверку SSL-сертификата',
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
'memories.testConnection': 'Проверить подключение', 'memories.testConnection': 'Проверить подключение',
'memories.testFirst': 'Сначала проверьте подключение', 'memories.testFirst': 'Сначала проверьте подключение',
'memories.connected': 'Подключено', 'memories.connected': 'Подключено',
'memories.disconnected': 'Не подключено', 'memories.disconnected': 'Не подключено',
'memories.connectionSuccess': 'Подключение к Immich установлено', 'memories.connectionSuccess': 'Подключение к Immich установлено',
'memories.connectionError': 'Не удалось подключиться к Immich', 'memories.connectionError': 'Не удалось подключиться к Immich',
'memories.saved': 'Настройки Immich сохранены', 'memories.saved': 'Настройки {provider_name} сохранены',
'memories.providerDisconnectedBanner': 'Соединение с {provider_name} потеряно. Переподключитесь в Настройках для просмотра фотографий.',
'memories.saveError': 'Не удалось сохранить настройки {provider_name}',
'memories.oldest': 'Сначала старые', 'memories.oldest': 'Сначала старые',
'memories.newest': 'Сначала новые', 'memories.newest': 'Сначала новые',
'memories.allLocations': 'Все места', 'memories.allLocations': 'Все места',
'memories.addPhotos': 'Добавить фото', 'memories.addPhotos': 'Добавить фото',
'memories.linkAlbum': 'Привязать альбом', 'memories.linkAlbum': 'Привязать альбом',
'memories.selectAlbum': 'Выбрать альбом Immich', 'memories.selectAlbum': 'Выбрать альбом Immich',
'memories.selectAlbumMultiple': 'Выбрать альбом',
'memories.noAlbums': 'Альбомы не найдены', 'memories.noAlbums': 'Альбомы не найдены',
'memories.syncAlbum': 'Синхронизировать', 'memories.syncAlbum': 'Синхронизировать',
'memories.unlinkAlbum': 'Отвязать', 'memories.unlinkAlbum': 'Отвязать',
'memories.photos': 'фото', 'memories.photos': 'фото',
'memories.selectPhotos': 'Выбрать фото из Immich', 'memories.selectPhotos': 'Выбрать фото из Immich',
'memories.selectPhotosMultiple': 'Выбрать фотографии',
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.', 'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
'memories.selected': 'выбрано', 'memories.selected': 'выбрано',
'memories.addSelected': 'Добавить {count} фото', 'memories.addSelected': 'Добавить {count} фото',
@@ -1570,6 +1679,8 @@ const ru: Record<string, string> = {
'notifications.markUnread': 'Отметить как непрочитанное', 'notifications.markUnread': 'Отметить как непрочитанное',
'notifications.delete': 'Удалить', 'notifications.delete': 'Удалить',
'notifications.system': 'Система', 'notifications.system': 'Система',
'notifications.synologySessionCleared.title': 'Synology Photos отключено',
'notifications.synologySessionCleared.text': 'Ваш сервер или аккаунт изменился — перейдите в Настройки, чтобы проверить соединение снова.',
'memories.error.loadAlbums': 'Не удалось загрузить альбомы', 'memories.error.loadAlbums': 'Не удалось загрузить альбомы',
'memories.error.linkAlbum': 'Не удалось привязать альбом', 'memories.error.linkAlbum': 'Не удалось привязать альбом',
'memories.error.unlinkAlbum': 'Не удалось отвязать альбом', 'memories.error.unlinkAlbum': 'Не удалось отвязать альбом',
@@ -1692,6 +1803,309 @@ const ru: Record<string, string> = {
'notif.generic.text': 'У вас новое уведомление', 'notif.generic.text': 'У вас новое уведомление',
'notif.dev.unknown_event.title': '[DEV] Неизвестное событие', 'notif.dev.unknown_event.title': '[DEV] Неизвестное событие',
'notif.dev.unknown_event.text': 'Тип события "{event}" не зарегистрирован в EVENT_NOTIFICATION_CONFIG', 'notif.dev.unknown_event.text': 'Тип события "{event}" не зарегистрирован в EVENT_NOTIFICATION_CONFIG',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': 'только что',
'common.hoursAgo': '{count} ч назад',
'common.daysAgo': '{count} д назад',
'budget.linkedToReservation': 'Привязано к бронированию — измените название там',
'packing.saveAsTemplate': 'Сохранить как шаблон',
'packing.templateName': 'Название шаблона',
'packing.templateSaved': 'Список вещей сохранён как шаблон',
'memories.notConnectedMultipleHint': 'Подключите любого из этих фото-провайдеров: {provider_names} в Настройках, чтобы добавлять фото к этой поездке.',
'memories.providerUrl': 'URL сервера',
'memories.providerApiKey': 'API-ключ',
'memories.providerUsername': 'Имя пользователя',
'memories.providerPassword': 'Пароль',
'memories.saveError': 'Не удалось сохранить настройки {provider_name}',
'memories.saveRouteNotConfigured': 'Маршрут сохранения не настроен для этого провайдера',
'memories.testRouteNotConfigured': 'Маршрут тестирования не настроен для этого провайдера',
'memories.fillRequiredFields': 'Пожалуйста, заполните все обязательные поля',
'memories.selectAlbumMultiple': 'Выбрать альбом',
'memories.selectPhotosMultiple': 'Выбрать фото',
'journey.title': 'Путешествие',
'journey.subtitle': 'Отслеживайте свои путешествия в реальном времени',
'journey.new': 'Новое путешествие',
'journey.create': 'Создать',
'journey.titlePlaceholder': 'Куда вы едете?',
'journey.empty': 'Пока нет путешествий',
'journey.emptyHint': 'Начните документировать свою следующую поездку',
'journey.deleted': 'Путешествие удалено',
'journey.createError': 'Не удалось создать путешествие',
'journey.deleteError': 'Не удалось удалить путешествие',
'journey.deleteConfirmTitle': 'Удалить',
'journey.deleteConfirmMessage': 'Удалить «{title}»? Это действие нельзя отменить.',
'journey.deleteConfirmGeneric': 'Вы уверены, что хотите удалить это?',
'journey.notFound': 'Путешествие не найдено',
'journey.photos': 'Фото',
'journey.timelineEmpty': 'Пока нет остановок',
'journey.timelineEmptyHint': 'Добавьте отметку или напишите запись в дневник',
'journey.status.draft': 'Черновик',
'journey.status.active': 'Активно',
'journey.status.completed': 'Завершено',
'journey.status.upcoming': 'Предстоящее',
'journey.checkin.add': 'Отметиться',
'journey.checkin.namePlaceholder': 'Название места',
'journey.checkin.notesPlaceholder': 'Заметки (необязательно)',
'journey.checkin.save': 'Сохранить',
'journey.checkin.error': 'Не удалось сохранить отметку',
'journey.entry.add': 'Дневник',
'journey.entry.edit': 'Редактировать запись',
'journey.entry.titlePlaceholder': 'Заголовок (необязательно)',
'journey.entry.bodyPlaceholder': 'Что произошло сегодня?',
'journey.entry.save': 'Сохранить',
'journey.entry.error': 'Не удалось сохранить запись',
'journey.photo.add': 'Фото',
'journey.photo.uploadError': 'Загрузка не удалась',
'journey.share.share': 'Поделиться',
'journey.share.public': 'Публичный',
'journey.share.linkCopied': 'Публичная ссылка скопирована',
'journey.share.disabled': 'Публичный доступ отключён',
'journey.editor.titlePlaceholder': 'Дайте название этому моменту...',
'journey.editor.bodyPlaceholder': 'Расскажите историю этого дня...',
'journey.editor.placePlaceholder': 'Местоположение (необязательно)',
'journey.editor.tagsPlaceholder': 'Теги: скрытая жемчужина, лучшая еда, стоит вернуться...',
'journey.visibility.private': 'Приватный',
'journey.visibility.shared': 'Общий',
'journey.visibility.public': 'Публичный',
'journey.emptyState.title': 'Ваша история начинается здесь',
'journey.emptyState.subtitle': 'Отметьтесь в месте или напишите первую запись в дневник',
'journey.frontpage.subtitle': 'Превращайте поездки в истории, которые вы никогда не забудете',
'journey.frontpage.createJourney': 'Создать путешествие',
'journey.frontpage.activeJourney': 'Активное путешествие',
'journey.frontpage.allJourneys': 'Все путешествия',
'journey.frontpage.journeys': 'путешествий',
'journey.frontpage.createNew': 'Создать новое путешествие',
'journey.frontpage.createNewSub': 'Выберите поездки, пишите истории, делитесь приключениями',
'journey.frontpage.live': 'В эфире',
'journey.frontpage.synced': 'Синхронизировано',
'journey.frontpage.continueWriting': 'Продолжить писать',
'journey.frontpage.updated': 'Обновлено {time}',
'journey.frontpage.suggestionLabel': 'Поездка только что завершилась',
'journey.frontpage.suggestionText': 'Превратите <strong>{title}</strong> в путешествие',
'journey.frontpage.dismiss': 'Скрыть',
'journey.frontpage.journeyName': 'Название путешествия',
'journey.frontpage.namePlaceholder': 'напр. Юго-Восточная Азия 2026',
'journey.frontpage.selectTrips': 'Выбрать поездки',
'journey.frontpage.tripsSelected': 'поездок выбрано',
'journey.frontpage.trips': 'поездок',
'journey.frontpage.placesImported': 'мест будет импортировано',
'journey.frontpage.places': 'мест',
'journey.detail.backToJourney': 'Назад к путешествию',
'journey.detail.syncedWithTrips': 'Синхронизировано с поездками',
'journey.detail.addEntry': 'Добавить запись',
'journey.detail.newEntry': 'Новая запись',
'journey.detail.editEntry': 'Редактировать запись',
'journey.detail.noEntries': 'Пока нет записей',
'journey.detail.noEntriesHint': 'Добавьте поездку, чтобы начать с шаблонных записей',
'journey.detail.noPhotos': 'Пока нет фото',
'journey.detail.noPhotosHint': 'Загрузите фото в записи или просмотрите библиотеку Immich/Synology',
'journey.detail.journeyStats': 'Статистика путешествия',
'journey.detail.syncedTrips': 'Синхронизированные поездки',
'journey.detail.noTripsLinked': 'Пока нет привязанных поездок',
'journey.detail.contributors': 'Участники',
'journey.detail.readMore': 'Читать далее',
'journey.detail.prosCons': 'Плюсы и минусы',
'journey.stats.days': 'Дней',
'journey.stats.cities': 'Городов',
'journey.stats.entries': 'Записей',
'journey.stats.photos': 'Фото',
'journey.stats.places': 'Мест',
'journey.verdict.lovedIt': 'Понравилось',
'journey.verdict.couldBeBetter': 'Могло быть лучше',
'journey.synced.places': 'мест',
'journey.synced.synced': 'синхронизировано',
'journey.editor.uploadPhotos': 'Загрузить фото',
'journey.editor.fromGallery': 'Из галереи',
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
'journey.editor.writeStory': 'Напишите свою историю...',
'journey.editor.prosCons': 'Плюсы и минусы',
'journey.editor.pros': 'Плюсы',
'journey.editor.cons': 'Минусы',
'journey.editor.proPlaceholder': 'Что-то отличное...',
'journey.editor.conPlaceholder': 'Не очень хорошо...',
'journey.editor.addAnother': 'Добавить ещё',
'journey.editor.date': 'Дата',
'journey.editor.location': 'Местоположение',
'journey.editor.searchLocation': 'Поиск местоположения...',
'journey.editor.mood': 'Настроение',
'journey.editor.weather': 'Погода',
'journey.editor.photoFirst': '1-е',
'journey.editor.makeFirst': 'Сделать 1-м',
'journey.mood.amazing': 'Потрясающе',
'journey.mood.good': 'Хорошо',
'journey.mood.neutral': 'Нейтрально',
'journey.mood.rough': 'Тяжело',
'journey.weather.sunny': 'Солнечно',
'journey.weather.partly': 'Переменная облачность',
'journey.weather.cloudy': 'Облачно',
'journey.weather.rainy': 'Дождливо',
'journey.weather.stormy': 'Гроза',
'journey.weather.cold': 'Снежно',
'journey.trips.linkTrip': 'Привязать поездку',
'journey.trips.searchTrip': 'Поиск поездки',
'journey.trips.searchPlaceholder': 'Название поездки или направление...',
'journey.trips.noTripsAvailable': 'Нет доступных поездок',
'journey.trips.link': 'Привязать',
'journey.trips.tripLinked': 'Поездка привязана',
'journey.trips.linkFailed': 'Не удалось привязать поездку',
'journey.trips.addTrip': 'Добавить поездку',
'journey.trips.unlinkTrip': 'Отвязать поездку',
'journey.trips.unlinkMessage': 'Отвязать «{title}»? Все синхронизированные записи и фото из этой поездки будут безвозвратно удалены. Это действие нельзя отменить.',
'journey.trips.unlink': 'Отвязать',
'journey.trips.tripUnlinked': 'Поездка отвязана',
'journey.trips.unlinkFailed': 'Не удалось отвязать поездку',
'journey.trips.noTripsLinkedSettings': 'Нет привязанных поездок',
'journey.contributors.invite': 'Пригласить участника',
'journey.contributors.searchUser': 'Поиск пользователя',
'journey.contributors.searchPlaceholder': 'Имя пользователя или email...',
'journey.contributors.noUsers': 'Пользователи не найдены',
'journey.contributors.role': 'Роль',
'journey.contributors.added': 'Участник добавлен',
'journey.contributors.addFailed': 'Не удалось добавить участника',
'journey.share.publicShare': 'Публичный доступ',
'journey.share.createLink': 'Создать ссылку для общего доступа',
'journey.share.linkCreated': 'Ссылка создана',
'journey.share.createFailed': 'Не удалось создать ссылку',
'journey.share.copy': 'Копировать',
'journey.share.copied': 'Скопировано!',
'journey.share.timeline': 'Хронология',
'journey.share.gallery': 'Галерея',
'journey.share.map': 'Карта',
'journey.share.removeLink': 'Удалить ссылку',
'journey.share.linkDeleted': 'Ссылка удалена',
'journey.share.deleteFailed': 'Не удалось удалить',
'journey.share.updateFailed': 'Не удалось обновить',
'journey.settings.title': 'Настройки путешествия',
'journey.settings.coverImage': 'Обложка',
'journey.settings.changeCover': 'Сменить обложку',
'journey.settings.addCover': 'Добавить обложку',
'journey.settings.name': 'Название',
'journey.settings.subtitle': 'Подзаголовок',
'journey.settings.subtitlePlaceholder': 'напр. Таиланд, Вьетнам и Камбоджа',
'journey.settings.delete': 'Удалить',
'journey.settings.deleteJourney': 'Удалить путешествие',
'journey.settings.deleteMessage': 'Удалить «{title}»? Все записи и фото будут потеряны.',
'journey.settings.saved': 'Настройки сохранены',
'journey.settings.saveFailed': 'Не удалось сохранить',
'journey.settings.coverUpdated': 'Обложка обновлена',
'journey.settings.coverFailed': 'Загрузка не удалась',
'journey.settings.failedToDelete': 'Не удалось удалить',
'journey.entries.deleteTitle': 'Удалить запись',
'journey.photosUploaded': '{count} фото загружено',
'journey.photosAdded': '{count} фото добавлено',
'journey.public.notFound': 'Не найдено',
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
'journey.public.readOnly': 'Только для чтения · Публичное путешествие',
'journey.public.tagline': 'Инструмент планирования и исследования путешествий',
'journey.public.sharedVia': 'Опубликовано через',
'journey.public.madeWith': 'Сделано с помощью',
'journey.pdf.journeyBook': 'Книга путешествия',
'journey.pdf.madeWith': 'Сделано с помощью TREK',
'journey.pdf.day': 'День',
'journey.pdf.theEnd': 'Конец',
'journey.pdf.saveAsPdf': 'Сохранить как PDF',
'journey.pdf.pages': 'страниц',
'dashboard.greeting.morning': 'Доброе утро,',
'dashboard.greeting.afternoon': 'Добрый день,',
'dashboard.greeting.evening': 'Добрый вечер,',
'dashboard.mobile.liveNow': 'Сейчас в пути',
'dashboard.mobile.tripProgress': 'Прогресс поездки',
'dashboard.mobile.daysLeft': 'осталось {count} дн.',
'dashboard.mobile.places': 'Места',
'dashboard.mobile.buddies': 'Попутчики',
'dashboard.mobile.newTrip': 'Новая поездка',
'dashboard.mobile.currency': 'Валюта',
'dashboard.mobile.timezone': 'Часовой пояс',
'dashboard.mobile.upcomingTrips': 'Предстоящие поездки',
'dashboard.mobile.yourTrips': 'Ваши поездки',
'dashboard.mobile.trips': 'поездок',
'dashboard.mobile.starts': 'Начало',
'dashboard.mobile.duration': 'Продолжительность',
'dashboard.mobile.day': 'день',
'dashboard.mobile.days': 'дней',
'dashboard.mobile.ongoing': 'В процессе',
'dashboard.mobile.startsToday': 'Начинается сегодня',
'dashboard.mobile.tomorrow': 'Завтра',
'dashboard.mobile.inDays': 'Через {count} дн.',
'dashboard.mobile.inMonths': 'Через {count} мес.',
'dashboard.mobile.completed': 'Завершено',
'dashboard.mobile.currencyConverter': 'Конвертер валют',
'nav.profile': 'Профиль',
'nav.bottomSettings': 'Настройки',
'nav.bottomAdmin': 'Администрирование',
'nav.bottomLogout': 'Выйти',
'nav.bottomAdminBadge': 'Админ',
'dayplan.mobile.addPlace': 'Добавить место',
'dayplan.mobile.searchPlaces': 'Поиск мест...',
'dayplan.mobile.allAssigned': 'Все места распределены',
'dayplan.mobile.noMatch': 'Нет совпадений',
'dayplan.mobile.createNew': 'Создать новое место',
'admin.addons.catalog.journey.name': 'Путешествие',
'admin.addons.catalog.journey.description': 'Отслеживание поездок и дневник путешествий с отметками, фото и ежедневными историями',
// OAuth scope groups
'oauth.scope.group.trips': 'Поездки',
'oauth.scope.group.places': 'Места',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': 'Вещи',
'oauth.scope.group.todos': 'Задачи',
'oauth.scope.group.budget': 'Бюджет',
'oauth.scope.group.reservations': 'Бронирования',
'oauth.scope.group.collab': 'Сотрудничество',
'oauth.scope.group.notifications': 'Уведомления',
'oauth.scope.group.vacay': 'Отпуск',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': 'Погода',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': 'Просмотр поездок и маршрутов',
'oauth.scope.trips:read.description': 'Чтение поездок, дней, заметок и участников',
'oauth.scope.trips:write.label': 'Редактирование поездок и маршрутов',
'oauth.scope.trips:write.description': 'Создание и обновление поездок, дней, заметок и управление участниками',
'oauth.scope.trips:delete.label': 'Удаление поездок',
'oauth.scope.trips:delete.description': 'Безвозвратное удаление поездок — это действие необратимо',
'oauth.scope.trips:share.label': 'Управление ссылками на совместный доступ',
'oauth.scope.trips:share.description': 'Создание, обновление и отзыв публичных ссылок на поездки',
'oauth.scope.places:read.label': 'Просмотр мест и данных карты',
'oauth.scope.places:read.description': 'Чтение мест, назначений по дням, тегов и категорий',
'oauth.scope.places:write.label': 'Управление местами',
'oauth.scope.places:write.description': 'Создание, обновление и удаление мест, назначений и тегов',
'oauth.scope.atlas:read.label': 'Просмотр Atlas',
'oauth.scope.atlas:read.description': 'Чтение посещённых стран, регионов и списка желаний',
'oauth.scope.atlas:write.label': 'Управление Atlas',
'oauth.scope.atlas:write.description': 'Отмечать посещённые страны и регионы, управлять списком желаний',
'oauth.scope.packing:read.label': 'Просмотр списков вещей',
'oauth.scope.packing:read.description': 'Чтение вещей, сумок и назначений категорий',
'oauth.scope.packing:write.label': 'Управление списками вещей',
'oauth.scope.packing:write.description': 'Добавление, обновление, удаление, отметка и переупорядочивание вещей и сумок',
'oauth.scope.todos:read.label': 'Просмотр списков задач',
'oauth.scope.todos:read.description': 'Чтение задач поездки и назначений категорий',
'oauth.scope.todos:write.label': 'Управление списками задач',
'oauth.scope.todos:write.description': 'Создание, обновление, отметка, удаление и переупорядочивание задач',
'oauth.scope.budget:read.label': 'Просмотр бюджета',
'oauth.scope.budget:read.description': 'Чтение статей бюджета и разбивки расходов',
'oauth.scope.budget:write.label': 'Управление бюджетом',
'oauth.scope.budget:write.description': 'Создание, обновление и удаление статей бюджета',
'oauth.scope.reservations:read.label': 'Просмотр бронирований',
'oauth.scope.reservations:read.description': 'Чтение бронирований и сведений о проживании',
'oauth.scope.reservations:write.label': 'Управление бронированиями',
'oauth.scope.reservations:write.description': 'Создание, обновление, удаление и переупорядочивание бронирований',
'oauth.scope.collab:read.label': 'Просмотр совместной работы',
'oauth.scope.collab:read.description': 'Чтение совместных заметок, опросов и сообщений',
'oauth.scope.collab:write.label': 'Управление совместной работой',
'oauth.scope.collab:write.description': 'Создание, обновление и удаление заметок, опросов и сообщений',
'oauth.scope.notifications:read.label': 'Просмотр уведомлений',
'oauth.scope.notifications:read.description': 'Чтение уведомлений в приложении и количества непрочитанных',
'oauth.scope.notifications:write.label': 'Управление уведомлениями',
'oauth.scope.notifications:write.description': 'Отмечать уведомления как прочитанные и отвечать на них',
'oauth.scope.vacay:read.label': 'Просмотр планов отпуска',
'oauth.scope.vacay:read.description': 'Чтение данных планирования отпуска, записей и статистики',
'oauth.scope.vacay:write.label': 'Управление планами отпуска',
'oauth.scope.vacay:write.description': 'Создание и управление записями отпуска, праздниками и командными планами',
'oauth.scope.geo:read.label': 'Карты и геокодирование',
'oauth.scope.geo:read.description': 'Поиск мест, разрешение URL карт и обратное геокодирование координат',
'oauth.scope.weather:read.label': 'Прогнозы погоды',
'oauth.scope.weather:read.description': 'Получение прогнозов погоды для мест и дат поездки',
} }
export default ru export default ru
+424 -10
View File
@@ -8,6 +8,8 @@ const zh: Record<string, string> = {
'common.loading': '加载中...', 'common.loading': '加载中...',
'common.import': '导入', 'common.import': '导入',
'common.error': '错误', 'common.error': '错误',
'common.unknownError': '未知错误',
'common.tooManyAttempts': '尝试次数过多,请稍后再试。',
'common.back': '返回', 'common.back': '返回',
'common.all': '全部', 'common.all': '全部',
'common.close': '关闭', 'common.close': '关闭',
@@ -27,6 +29,12 @@ const zh: Record<string, string> = {
'common.password': '密码', 'common.password': '密码',
'common.saving': '保存中...', 'common.saving': '保存中...',
'common.saved': '已保存', 'common.saved': '已保存',
'common.expand': '展开',
'common.collapse': '折叠',
'trips.memberRemoved': '{username} 已移除',
'trips.memberRemoveError': '移除失败',
'trips.memberAdded': '{username} 已添加',
'trips.memberAddError': '添加失败',
'trips.reminder': '提醒', 'trips.reminder': '提醒',
'trips.reminderNone': '无', 'trips.reminderNone': '无',
'trips.reminderDay': '天', 'trips.reminderDay': '天',
@@ -179,9 +187,6 @@ const zh: Record<string, string> = {
'admin.notifications.none': '已禁用', 'admin.notifications.none': '已禁用',
'admin.notifications.email': '电子邮件 (SMTP)', 'admin.notifications.email': '电子邮件 (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': '通知事件',
'admin.notifications.eventsHint': '选择哪些事件为所有用户触发通知。',
'admin.notifications.configureFirst': '请先在下方配置 SMTP 或 Webhook,然后启用事件。',
'admin.notifications.save': '保存通知设置', 'admin.notifications.save': '保存通知设置',
'admin.notifications.saved': '通知设置已保存', 'admin.notifications.saved': '通知设置已保存',
'admin.notifications.testWebhook': '发送测试 Webhook', 'admin.notifications.testWebhook': '发送测试 Webhook',
@@ -228,6 +233,7 @@ const zh: Record<string, string> = {
'settings.mcp.endpoint': 'MCP 端点', 'settings.mcp.endpoint': 'MCP 端点',
'settings.mcp.clientConfig': '客户端配置', 'settings.mcp.clientConfig': '客户端配置',
'settings.mcp.clientConfigHint': '将 <your_token> 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。', 'settings.mcp.clientConfigHint': '将 <your_token> 替换为下方列表中的 API 令牌。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
'settings.mcp.clientConfigHintOAuth': '将 <your_client_id> 和 <your_client_secret> 替换为上方创建的 OAuth 2.1 客户端凭据。首次连接时,mcp-remote 将打开浏览器完成授权。npx 的路径可能需要根据您的系统进行调整(例如 Windows 上为 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
'settings.mcp.copy': '复制', 'settings.mcp.copy': '复制',
'settings.mcp.copied': '已复制!', 'settings.mcp.copied': '已复制!',
'settings.mcp.apiTokens': 'API 令牌', 'settings.mcp.apiTokens': 'API 令牌',
@@ -249,6 +255,48 @@ const zh: Record<string, string> = {
'settings.mcp.toast.createError': '创建令牌失败', 'settings.mcp.toast.createError': '创建令牌失败',
'settings.mcp.toast.deleted': '令牌已删除', 'settings.mcp.toast.deleted': '令牌已删除',
'settings.mcp.toast.deleteError': '删除令牌失败', 'settings.mcp.toast.deleteError': '删除令牌失败',
'settings.mcp.apiTokensDeprecated': 'API 令牌已弃用,将在未来版本中移除。请改用 OAuth 2.1 客户端。',
'settings.oauth.clients': 'OAuth 2.1 客户端',
'settings.oauth.clientsHint': '注册 OAuth 2.1 客户端,让第三方 MCP 应用程序(Claude Web、Cursor 等)无需静态令牌即可连接。',
'settings.oauth.createClient': '新建客户端',
'settings.oauth.noClients': '没有已注册的 OAuth 客户端。',
'settings.oauth.clientId': '客户端 ID',
'settings.oauth.clientSecret': '客户端密钥',
'settings.oauth.deleteClient': '删除客户端',
'settings.oauth.deleteClientMessage': '此客户端及所有活跃会话将被永久删除。使用此客户端的任何应用程序将立即失去访问权限。',
'settings.oauth.rotateSecret': '轮换密钥',
'settings.oauth.rotateSecretMessage': '将生成新的客户端密钥,所有现有会话将立即失效。在关闭此对话框之前,请更新您的应用程序。',
'settings.oauth.rotateSecretConfirm': '轮换',
'settings.oauth.rotateSecretConfirming': '轮换中…',
'settings.oauth.rotateSecretDoneTitle': '已生成新密钥',
'settings.oauth.rotateSecretDoneWarning': '此密钥仅显示一次。请立即复制并更新您的应用程序——所有之前的会话已失效。',
'settings.oauth.activeSessions': '活跃的 OAuth 会话',
'settings.oauth.sessionScopes': '权限范围',
'settings.oauth.sessionExpires': '过期时间',
'settings.oauth.revoke': '撤销',
'settings.oauth.revokeSession': '撤销会话',
'settings.oauth.revokeSessionMessage': '这将立即撤销此 OAuth 会话的访问权限。',
'settings.oauth.modal.createTitle': '注册 OAuth 客户端',
'settings.oauth.modal.presets': '快速预设',
'settings.oauth.modal.clientName': '应用程序名称',
'settings.oauth.modal.clientNamePlaceholder': '例如 Claude Web、我的 MCP 应用',
'settings.oauth.modal.redirectUris': '重定向 URI',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': '每行一个 URI。需要 HTTPSlocalhost 除外)。要求精确匹配。',
'settings.oauth.modal.scopes': '允许的权限范围',
'settings.oauth.modal.scopesHint': 'list_trips 和 get_trip_summary 始终可用——无需权限范围。它们帮助 AI 发现所需的行程 ID。',
'settings.oauth.modal.selectAll': '全选',
'settings.oauth.modal.deselectAll': '取消全选',
'settings.oauth.modal.creating': '注册中…',
'settings.oauth.modal.create': '注册客户端',
'settings.oauth.modal.createdTitle': '客户端已注册',
'settings.oauth.modal.createdWarning': '客户端密钥仅显示一次。请立即复制——无法恢复。',
'settings.oauth.toast.createError': '注册 OAuth 客户端失败',
'settings.oauth.toast.deleted': 'OAuth 客户端已删除',
'settings.oauth.toast.deleteError': '删除 OAuth 客户端失败',
'settings.oauth.toast.revoked': '会话已撤销',
'settings.oauth.toast.revokeError': '撤销会话失败',
'settings.oauth.toast.rotateError': '轮换客户端密钥失败',
'settings.account': '账户', 'settings.account': '账户',
'settings.about': '关于', 'settings.about': '关于',
'settings.about.reportBug': '报告错误', 'settings.about.reportBug': '报告错误',
@@ -364,6 +412,10 @@ const zh: Record<string, string> = {
'login.mfaHint': '打开 Google Authenticator、Authy 或其他 TOTP 应用。', 'login.mfaHint': '打开 Google Authenticator、Authy 或其他 TOTP 应用。',
'login.mfaBack': '← 返回登录', 'login.mfaBack': '← 返回登录',
'login.mfaVerify': '验证', 'login.mfaVerify': '验证',
'login.invalidInviteLink': '邀请链接无效或已过期',
'login.oidcFailed': 'OIDC 登录失败',
'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符',
'login.oidc.tokenFailed': '认证失败。', 'login.oidc.tokenFailed': '认证失败。',
'login.oidc.invalidState': '会话无效,请重试。', 'login.oidc.invalidState': '会话无效,请重试。',
'login.demoFailed': '演示登录失败', 'login.demoFailed': '演示登录失败',
@@ -450,6 +502,17 @@ const zh: Record<string, string> = {
'admin.tabs.settings': '设置', 'admin.tabs.settings': '设置',
'admin.allowRegistration': '允许注册', 'admin.allowRegistration': '允许注册',
'admin.allowRegistrationHint': '新用户可以自行注册', 'admin.allowRegistrationHint': '新用户可以自行注册',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': '要求双因素身份验证(2FA', 'admin.requireMfa': '要求双因素身份验证(2FA',
'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。', 'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。',
'admin.apiKeys': 'API 密钥', 'admin.apiKeys': 'API 密钥',
@@ -547,9 +610,10 @@ const zh: Record<string, string> = {
'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。', 'admin.weather.locationHint': '天气基于每天中第一个有坐标的地点。如果当天没有分配地点,则使用地点列表中的任意地点作为参考。',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'MCP 令牌', 'admin.tabs.mcpTokens': 'MCP 访问',
'admin.mcpTokens.title': 'MCP 令牌', 'admin.mcpTokens.title': 'MCP 访问',
'admin.mcpTokens.subtitle': '管理所有用户的 API 令牌', 'admin.mcpTokens.subtitle': '管理所有用户的 OAuth 会话和 API 令牌',
'admin.mcpTokens.sectionTitle': 'API 令牌',
'admin.mcpTokens.owner': '所有者', 'admin.mcpTokens.owner': '所有者',
'admin.mcpTokens.tokenName': '令牌名称', 'admin.mcpTokens.tokenName': '令牌名称',
'admin.mcpTokens.created': '创建时间', 'admin.mcpTokens.created': '创建时间',
@@ -561,6 +625,17 @@ const zh: Record<string, string> = {
'admin.mcpTokens.deleteSuccess': '令牌已删除', 'admin.mcpTokens.deleteSuccess': '令牌已删除',
'admin.mcpTokens.deleteError': '删除令牌失败', 'admin.mcpTokens.deleteError': '删除令牌失败',
'admin.mcpTokens.loadError': '加载令牌失败', 'admin.mcpTokens.loadError': '加载令牌失败',
'admin.oauthSessions.sectionTitle': 'OAuth 会话',
'admin.oauthSessions.clientName': '客户端',
'admin.oauthSessions.owner': '所有者',
'admin.oauthSessions.scopes': '权限范围',
'admin.oauthSessions.created': '创建时间',
'admin.oauthSessions.empty': '暂无活跃的 OAuth 会话',
'admin.oauthSessions.revokeTitle': '撤销会话',
'admin.oauthSessions.revokeMessage': '此 OAuth 会话将立即被撤销。客户端将失去 MCP 访问权限。',
'admin.oauthSessions.revokeSuccess': '会话已撤销',
'admin.oauthSessions.revokeError': '撤销会话失败',
'admin.oauthSessions.loadError': '加载 OAuth 会话失败',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -656,6 +731,8 @@ const zh: Record<string, string> = {
'vacay.companyHolidays': '公司假日', 'vacay.companyHolidays': '公司假日',
'vacay.companyHolidaysHint': '允许标记公司统一休假日', 'vacay.companyHolidaysHint': '允许标记公司统一休假日',
'vacay.companyHolidaysNoDeduct': '公司假日不计入年假天数。', 'vacay.companyHolidaysNoDeduct': '公司假日不计入年假天数。',
'vacay.weekStart': '每周开始于',
'vacay.weekStartHint': '选择日历周从周一还是周日开始',
'vacay.carryOver': '结转', 'vacay.carryOver': '结转',
'vacay.carryOverHint': '自动将剩余年假天数结转到下一年', 'vacay.carryOverHint': '自动将剩余年假天数结转到下一年',
'vacay.sharing': '共享', 'vacay.sharing': '共享',
@@ -870,6 +947,7 @@ const zh: Record<string, string> = {
'inspector.files': '文件', 'inspector.files': '文件',
'inspector.filesCount': '{count} 个文件', 'inspector.filesCount': '{count} 个文件',
'inspector.removeFromDay': '从当天移除', 'inspector.removeFromDay': '从当天移除',
'inspector.remove': '删除',
'inspector.addToDay': '添加到当天', 'inspector.addToDay': '添加到当天',
'inspector.confirmedRes': '已确认预订', 'inspector.confirmedRes': '已确认预订',
'inspector.pendingRes': '待确认预订', 'inspector.pendingRes': '待确认预订',
@@ -1010,6 +1088,7 @@ const zh: Record<string, string> = {
'budget.totalBudget': '总预算', 'budget.totalBudget': '总预算',
'budget.byCategory': '按分类', 'budget.byCategory': '按分类',
'budget.editTooltip': '点击编辑', 'budget.editTooltip': '点击编辑',
'budget.linkedToReservation': '已关联到预订——请在那里编辑名称',
'budget.confirm.deleteCategory': '确定删除分类「{name}」及其 {count} 个条目?', 'budget.confirm.deleteCategory': '确定删除分类「{name}」及其 {count} 个条目?',
'budget.deleteCategory': '删除分类', 'budget.deleteCategory': '删除分类',
'budget.perPerson': '人均', 'budget.perPerson': '人均',
@@ -1019,9 +1098,13 @@ const zh: Record<string, string> = {
'budget.settlement': '结算', 'budget.settlement': '结算',
'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。', 'budget.settlementInfo': '点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
'budget.netBalances': '净余额', 'budget.netBalances': '净余额',
'budget.linkedToReservation': '已链接到预订——在那里编辑名称',
// Files // Files
'files.title': '文件', 'files.title': '文件',
'files.pageTitle': '文件与文档',
'files.subtitle': '{trip} 的 {count} 个文件',
'files.downloadPdf': '下载 PDF',
'files.count': '{count} 个文件', 'files.count': '{count} 个文件',
'files.countSingular': '1 个文件', 'files.countSingular': '1 个文件',
'files.uploaded': '已上传 {count} 个', 'files.uploaded': '已上传 {count} 个',
@@ -1108,7 +1191,9 @@ const zh: Record<string, string> = {
'packing.template': '模板', 'packing.template': '模板',
'packing.templateApplied': '已从模板添加 {count} 个物品', 'packing.templateApplied': '已从模板添加 {count} 个物品',
'packing.templateError': '应用模板失败', 'packing.templateError': '应用模板失败',
'packing.assignUser': '分配用户', 'packing.saveAsTemplate': '保存为模板',
'packing.templateName': '模板名称',
'packing.templateSaved': '行李清单已保存为模板',
'packing.noMembers': '无成员', 'packing.noMembers': '无成员',
'packing.bags': '行李', 'packing.bags': '行李',
'packing.noBag': '未分配', 'packing.noBag': '未分配',
@@ -1265,6 +1350,13 @@ const zh: Record<string, string> = {
'backup.keep.forever': '永久保留', 'backup.keep.forever': '永久保留',
// Photos // Photos
'photos.title': '照片',
'photos.subtitle': '{trip} 的 {count} 张照片',
'photos.dropHere': '将照片拖放至此...',
'photos.dropHereActive': '将照片拖放至此',
'photos.captionForAll': '标题(所有)',
'photos.captionPlaceholder': '可选标题...',
'photos.addCaption': '添加标题...',
'photos.allDays': '所有天', 'photos.allDays': '所有天',
'photos.noPhotos': '暂无照片', 'photos.noPhotos': '暂无照片',
'photos.uploadHint': '上传你的旅行照片', 'photos.uploadHint': '上传你的旅行照片',
@@ -1272,6 +1364,12 @@ const zh: Record<string, string> = {
'photos.linkPlace': '关联地点', 'photos.linkPlace': '关联地点',
'photos.noPlace': '无地点', 'photos.noPlace': '无地点',
'photos.uploadN': '上传 {n} 张照片', 'photos.uploadN': '上传 {n} 张照片',
'photos.linkDay': '关联天数',
'photos.noDay': '无天数',
'photos.dayLabel': '第 {number} 天',
'photos.photoSelected': '张照片已选择',
'photos.photosSelected': '张照片已选择',
'photos.fileTypeHint': 'JPG, PNG, WebP · 最大 10 MB · 最多 30 张照片',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': '恢复备份?', 'backup.restoreConfirmTitle': '恢复备份?',
@@ -1298,6 +1396,7 @@ const zh: Record<string, string> = {
'planner.routeCalculated': '路线已计算', 'planner.routeCalculated': '路线已计算',
'planner.routeCalcFailed': '无法计算路线', 'planner.routeCalcFailed': '无法计算路线',
'planner.routeError': '路线计算错误', 'planner.routeError': '路线计算错误',
'planner.icsExportFailed': 'ICS 导出失败',
'planner.routeOptimized': '路线已优化', 'planner.routeOptimized': '路线已优化',
'planner.reservationUpdated': '预订已更新', 'planner.reservationUpdated': '预订已更新',
'planner.reservationAdded': '预订已添加', 'planner.reservationAdded': '预订已添加',
@@ -1383,6 +1482,7 @@ const zh: Record<string, string> = {
'memories.title': '照片', 'memories.title': '照片',
'memories.notConnected': 'Immich 未连接', 'memories.notConnected': 'Immich 未连接',
'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。', 'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。',
'memories.notConnectedMultipleHint': '请在设置中连接以下任一照片提供商:{provider_names},以便向此行程添加照片。',
'memories.noDates': '为旅行添加日期以加载照片。', 'memories.noDates': '为旅行添加日期以加载照片。',
'memories.noPhotos': '未找到照片', 'memories.noPhotos': '未找到照片',
'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。', 'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。',
@@ -1393,26 +1493,35 @@ const zh: Record<string, string> = {
'memories.reviewTitle': '审查您的照片', 'memories.reviewTitle': '审查您的照片',
'memories.reviewHint': '点击照片以将其从分享中排除。', 'memories.reviewHint': '点击照片以将其从分享中排除。',
'memories.shareCount': '分享 {count} 张照片', 'memories.shareCount': '分享 {count} 张照片',
'memories.immichUrl': 'Immich 服务器地址', 'memories.providerUrl': '服务器 URL',
'memories.immichApiKey': 'API 密钥', 'memories.providerApiKey': 'API 密钥',
'memories.providerUsername': '用户名',
'memories.providerPassword': '密码',
'memories.providerOTP': 'MFA 验证码(如已启用)',
'memories.skipSSLVerification': '跳过 SSL 证书验证',
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
'memories.testConnection': '测试连接', 'memories.testConnection': '测试连接',
'memories.testFirst': '请先测试连接', 'memories.testFirst': '请先测试连接',
'memories.connected': '已连接', 'memories.connected': '已连接',
'memories.disconnected': '未连接', 'memories.disconnected': '未连接',
'memories.connectionSuccess': '已连接到 Immich', 'memories.connectionSuccess': '已连接到 Immich',
'memories.connectionError': '无法连接到 Immich', 'memories.connectionError': '无法连接到 Immich',
'memories.saved': 'Immich 设置已保存', 'memories.saved': '{provider_name} 设置已保存',
'memories.providerDisconnectedBanner': '您与 {provider_name} 的连接已断开。请在设置中重新连接以查看照片。',
'memories.saveError': '无法保存 {provider_name} 设置',
'memories.oldest': '最早优先', 'memories.oldest': '最早优先',
'memories.newest': '最新优先', 'memories.newest': '最新优先',
'memories.allLocations': '所有地点', 'memories.allLocations': '所有地点',
'memories.addPhotos': '添加照片', 'memories.addPhotos': '添加照片',
'memories.linkAlbum': '关联相册', 'memories.linkAlbum': '关联相册',
'memories.selectAlbum': '选择 Immich 相册', 'memories.selectAlbum': '选择 Immich 相册',
'memories.selectAlbumMultiple': '选择相册',
'memories.noAlbums': '未找到相册', 'memories.noAlbums': '未找到相册',
'memories.syncAlbum': '同步相册', 'memories.syncAlbum': '同步相册',
'memories.unlinkAlbum': '取消关联', 'memories.unlinkAlbum': '取消关联',
'memories.photos': '张照片', 'memories.photos': '张照片',
'memories.selectPhotos': '从 Immich 选择照片', 'memories.selectPhotos': '从 Immich 选择照片',
'memories.selectPhotosMultiple': '选择照片',
'memories.selectHint': '点击照片以选择。', 'memories.selectHint': '点击照片以选择。',
'memories.selected': '已选择', 'memories.selected': '已选择',
'memories.addSelected': '添加 {count} 张照片', 'memories.addSelected': '添加 {count} 张照片',
@@ -1570,6 +1679,8 @@ const zh: Record<string, string> = {
'notifications.markUnread': '标为未读', 'notifications.markUnread': '标为未读',
'notifications.delete': '删除', 'notifications.delete': '删除',
'notifications.system': '系统', 'notifications.system': '系统',
'notifications.synologySessionCleared.title': 'Synology Photos 已断开连接',
'notifications.synologySessionCleared.text': '您的服务器或账户已更改 — 请前往设置重新测试您的连接。',
'memories.error.loadAlbums': '加载相册失败', 'memories.error.loadAlbums': '加载相册失败',
'memories.error.linkAlbum': '关联相册失败', 'memories.error.linkAlbum': '关联相册失败',
'memories.error.unlinkAlbum': '取消关联相册失败', 'memories.error.unlinkAlbum': '取消关联相册失败',
@@ -1692,6 +1803,309 @@ const zh: Record<string, string> = {
'notif.generic.text': '您有一条新通知', 'notif.generic.text': '您有一条新通知',
'notif.dev.unknown_event.title': '[DEV] 未知事件', 'notif.dev.unknown_event.title': '[DEV] 未知事件',
'notif.dev.unknown_event.text': '事件类型 "{event}" 未在 EVENT_NOTIFICATION_CONFIG 中注册', 'notif.dev.unknown_event.text': '事件类型 "{event}" 未在 EVENT_NOTIFICATION_CONFIG 中注册',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': '刚刚',
'common.hoursAgo': '{count}小时前',
'common.daysAgo': '{count}天前',
'budget.linkedToReservation': '已关联预订 — 请在预订中编辑名称',
'packing.saveAsTemplate': '保存为模板',
'packing.templateName': '模板名称',
'packing.templateSaved': '打包清单已保存为模板',
'memories.notConnectedMultipleHint': '在设置中连接以下任一照片服务:{provider_names},以便为此旅行添加照片。',
'memories.providerUrl': '服务器地址',
'memories.providerApiKey': 'API 密钥',
'memories.providerUsername': '用户名',
'memories.providerPassword': '密码',
'memories.saveError': '无法保存 {provider_name} 设置',
'memories.saveRouteNotConfigured': '此提供商未配置保存路由',
'memories.testRouteNotConfigured': '此提供商未配置测试路由',
'memories.fillRequiredFields': '请填写所有必填字段',
'memories.selectAlbumMultiple': '选择相册',
'memories.selectPhotosMultiple': '选择照片',
'journey.title': '旅程',
'journey.subtitle': '实时记录你的旅行',
'journey.new': '新建旅程',
'journey.create': '创建',
'journey.titlePlaceholder': '你要去哪里?',
'journey.empty': '还没有旅程',
'journey.emptyHint': '开始记录你的下一次旅行',
'journey.deleted': '旅程已删除',
'journey.createError': '无法创建旅程',
'journey.deleteError': '无法删除旅程',
'journey.deleteConfirmTitle': '删除',
'journey.deleteConfirmMessage': '删除"{title}"?此操作无法撤销。',
'journey.deleteConfirmGeneric': '确定要删除吗?',
'journey.notFound': '未找到旅程',
'journey.photos': '照片',
'journey.timelineEmpty': '还没有行程',
'journey.timelineEmptyHint': '添加一个签到或写一篇日志开始记录',
'journey.status.draft': '草稿',
'journey.status.active': '进行中',
'journey.status.completed': '已完成',
'journey.status.upcoming': '即将开始',
'journey.checkin.add': '签到',
'journey.checkin.namePlaceholder': '地点名称',
'journey.checkin.notesPlaceholder': '备注(可选)',
'journey.checkin.save': '保存',
'journey.checkin.error': '无法保存签到',
'journey.entry.add': '日志',
'journey.entry.edit': '编辑条目',
'journey.entry.titlePlaceholder': '标题(可选)',
'journey.entry.bodyPlaceholder': '今天发生了什么?',
'journey.entry.save': '保存',
'journey.entry.error': '无法保存条目',
'journey.photo.add': '照片',
'journey.photo.uploadError': '上传失败',
'journey.share.share': '分享',
'journey.share.public': '公开',
'journey.share.linkCopied': '公开链接已复制',
'journey.share.disabled': '已关闭公开分享',
'journey.editor.titlePlaceholder': '给这个瞬间起个名字...',
'journey.editor.bodyPlaceholder': '讲述这一天的故事...',
'journey.editor.placePlaceholder': '地点(可选)',
'journey.editor.tagsPlaceholder': '标签:隐藏宝藏、最佳美食、值得再去...',
'journey.visibility.private': '私密',
'journey.visibility.shared': '共享',
'journey.visibility.public': '公开',
'journey.emptyState.title': '你的故事从这里开始',
'journey.emptyState.subtitle': '在某个地方签到或写下你的第一篇日志',
'journey.frontpage.subtitle': '将旅行变成永远不会忘记的故事',
'journey.frontpage.createJourney': '创建旅程',
'journey.frontpage.activeJourney': '进行中的旅程',
'journey.frontpage.allJourneys': '所有旅程',
'journey.frontpage.journeys': '个旅程',
'journey.frontpage.createNew': '创建新旅程',
'journey.frontpage.createNewSub': '选择旅行、写故事、分享你的冒险',
'journey.frontpage.live': '实时',
'journey.frontpage.synced': '已同步',
'journey.frontpage.continueWriting': '继续写作',
'journey.frontpage.updated': '更新于 {time}',
'journey.frontpage.suggestionLabel': '旅行刚结束',
'journey.frontpage.suggestionText': '将 <strong>{title}</strong> 变成一段旅程',
'journey.frontpage.dismiss': '忽略',
'journey.frontpage.journeyName': '旅程名称',
'journey.frontpage.namePlaceholder': '例如 东南亚 2026',
'journey.frontpage.selectTrips': '选择旅行',
'journey.frontpage.tripsSelected': '个旅行已选择',
'journey.frontpage.trips': '个旅行',
'journey.frontpage.placesImported': '个地点将被导入',
'journey.frontpage.places': '个地点',
'journey.detail.backToJourney': '返回旅程',
'journey.detail.syncedWithTrips': '已与旅行同步',
'journey.detail.addEntry': '添加条目',
'journey.detail.newEntry': '新建条目',
'journey.detail.editEntry': '编辑条目',
'journey.detail.noEntries': '还没有条目',
'journey.detail.noEntriesHint': '添加一个旅行以生成骨架条目',
'journey.detail.noPhotos': '还没有照片',
'journey.detail.noPhotosHint': '上传照片到条目或浏览你的 Immich/Synology 相册',
'journey.detail.journeyStats': '旅程统计',
'journey.detail.syncedTrips': '已同步的旅行',
'journey.detail.noTripsLinked': '尚未关联旅行',
'journey.detail.contributors': '贡献者',
'journey.detail.readMore': '阅读更多',
'journey.detail.prosCons': '优缺点',
'journey.stats.days': '天',
'journey.stats.cities': '城市',
'journey.stats.entries': '条目',
'journey.stats.photos': '照片',
'journey.stats.places': '地点',
'journey.verdict.lovedIt': '非常喜欢',
'journey.verdict.couldBeBetter': '有待改进',
'journey.synced.places': '个地点',
'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上传照片',
'journey.editor.fromGallery': '从相册选择',
'journey.editor.allPhotosAdded': '所有照片已添加',
'journey.editor.writeStory': '写下你的故事...',
'journey.editor.prosCons': '优缺点',
'journey.editor.pros': '优点',
'journey.editor.cons': '缺点',
'journey.editor.proPlaceholder': '好的方面...',
'journey.editor.conPlaceholder': '不好的方面...',
'journey.editor.addAnother': '再添加一个',
'journey.editor.date': '日期',
'journey.editor.location': '地点',
'journey.editor.searchLocation': '搜索地点...',
'journey.editor.mood': '心情',
'journey.editor.weather': '天气',
'journey.editor.photoFirst': '第1张',
'journey.editor.makeFirst': '设为第1张',
'journey.mood.amazing': '太棒了',
'journey.mood.good': '不错',
'journey.mood.neutral': '一般',
'journey.mood.rough': '糟糕',
'journey.weather.sunny': '晴天',
'journey.weather.partly': '多云',
'journey.weather.cloudy': '阴天',
'journey.weather.rainy': '雨天',
'journey.weather.stormy': '暴风雨',
'journey.weather.cold': '雪天',
'journey.trips.linkTrip': '关联旅行',
'journey.trips.searchTrip': '搜索旅行',
'journey.trips.searchPlaceholder': '旅行名称或目的地...',
'journey.trips.noTripsAvailable': '没有可用的旅行',
'journey.trips.link': '关联',
'journey.trips.tripLinked': '旅行已关联',
'journey.trips.linkFailed': '关联旅行失败',
'journey.trips.addTrip': '添加旅行',
'journey.trips.unlinkTrip': '取消关联旅行',
'journey.trips.unlinkMessage': '取消关联"{title}"?此旅行中所有已同步的条目和照片将被永久删除。此操作无法撤销。',
'journey.trips.unlink': '取消关联',
'journey.trips.tripUnlinked': '旅行已取消关联',
'journey.trips.unlinkFailed': '取消关联失败',
'journey.trips.noTripsLinkedSettings': '未关联旅行',
'journey.contributors.invite': '邀请贡献者',
'journey.contributors.searchUser': '搜索用户',
'journey.contributors.searchPlaceholder': '用户名或邮箱...',
'journey.contributors.noUsers': '未找到用户',
'journey.contributors.role': '角色',
'journey.contributors.added': '贡献者已添加',
'journey.contributors.addFailed': '添加贡献者失败',
'journey.share.publicShare': '公开分享',
'journey.share.createLink': '创建分享链接',
'journey.share.linkCreated': '分享链接已创建',
'journey.share.createFailed': '创建链接失败',
'journey.share.copy': '复制',
'journey.share.copied': '已复制!',
'journey.share.timeline': '时间线',
'journey.share.gallery': '图库',
'journey.share.map': '地图',
'journey.share.removeLink': '移除分享链接',
'journey.share.linkDeleted': '分享链接已删除',
'journey.share.deleteFailed': '删除失败',
'journey.share.updateFailed': '更新失败',
'journey.settings.title': '旅程设置',
'journey.settings.coverImage': '封面图片',
'journey.settings.changeCover': '更换封面',
'journey.settings.addCover': '添加封面图片',
'journey.settings.name': '名称',
'journey.settings.subtitle': '副标题',
'journey.settings.subtitlePlaceholder': '例如 泰国、越南和柬埔寨',
'journey.settings.delete': '删除',
'journey.settings.deleteJourney': '删除旅程',
'journey.settings.deleteMessage': '删除"{title}"?所有条目和照片将丢失。',
'journey.settings.saved': '设置已保存',
'journey.settings.saveFailed': '保存失败',
'journey.settings.coverUpdated': '封面已更新',
'journey.settings.coverFailed': '上传失败',
'journey.settings.failedToDelete': '删除失败',
'journey.entries.deleteTitle': '删除条目',
'journey.photosUploaded': '{count} 张照片已上传',
'journey.photosAdded': '{count} 张照片已添加',
'journey.public.notFound': '未找到',
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
'journey.public.readOnly': '只读 · 公开旅程',
'journey.public.tagline': '旅行资源与探索工具包',
'journey.public.sharedVia': '分享自',
'journey.public.madeWith': '由',
'journey.pdf.journeyBook': '旅程手册',
'journey.pdf.madeWith': '由 TREK 制作',
'journey.pdf.day': '第',
'journey.pdf.theEnd': '终',
'journey.pdf.saveAsPdf': '保存为 PDF',
'journey.pdf.pages': '页',
'dashboard.greeting.morning': '早上好,',
'dashboard.greeting.afternoon': '下午好,',
'dashboard.greeting.evening': '晚上好,',
'dashboard.mobile.liveNow': '进行中',
'dashboard.mobile.tripProgress': '旅行进度',
'dashboard.mobile.daysLeft': '还剩 {count} 天',
'dashboard.mobile.places': '地点',
'dashboard.mobile.buddies': '旅伴',
'dashboard.mobile.newTrip': '新建旅行',
'dashboard.mobile.currency': '货币',
'dashboard.mobile.timezone': '时区',
'dashboard.mobile.upcomingTrips': '即将到来的旅行',
'dashboard.mobile.yourTrips': '我的旅行',
'dashboard.mobile.trips': '个旅行',
'dashboard.mobile.starts': '出发',
'dashboard.mobile.duration': '时长',
'dashboard.mobile.day': '天',
'dashboard.mobile.days': '天',
'dashboard.mobile.ongoing': '进行中',
'dashboard.mobile.startsToday': '今天出发',
'dashboard.mobile.tomorrow': '明天',
'dashboard.mobile.inDays': '{count} 天后',
'dashboard.mobile.inMonths': '{count} 个月后',
'dashboard.mobile.completed': '已完成',
'dashboard.mobile.currencyConverter': '汇率转换',
'nav.profile': '个人资料',
'nav.bottomSettings': '设置',
'nav.bottomAdmin': '管理设置',
'nav.bottomLogout': '退出登录',
'nav.bottomAdminBadge': '管理员',
'dayplan.mobile.addPlace': '添加地点',
'dayplan.mobile.searchPlaces': '搜索地点...',
'dayplan.mobile.allAssigned': '所有地点已分配',
'dayplan.mobile.noMatch': '无匹配',
'dayplan.mobile.createNew': '创建新地点',
'admin.addons.catalog.journey.name': '旅程',
'admin.addons.catalog.journey.description': '旅行追踪与旅行日志,包含签到、照片和每日故事',
// OAuth scope groups
'oauth.scope.group.trips': '行程',
'oauth.scope.group.places': '地点',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': '行李',
'oauth.scope.group.todos': '待办事项',
'oauth.scope.group.budget': '预算',
'oauth.scope.group.reservations': '预订',
'oauth.scope.group.collab': '协作',
'oauth.scope.group.notifications': '通知',
'oauth.scope.group.vacay': '假期',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': '天气',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': '查看行程和行程计划',
'oauth.scope.trips:read.description': '读取行程、天数、每日笔记和成员',
'oauth.scope.trips:write.label': '编辑行程和行程计划',
'oauth.scope.trips:write.description': '创建和更新行程、天数、笔记并管理成员',
'oauth.scope.trips:delete.label': '删除行程',
'oauth.scope.trips:delete.description': '永久删除整个行程——此操作不可撤销',
'oauth.scope.trips:share.label': '管理分享链接',
'oauth.scope.trips:share.description': '创建、更新和撤销行程的公开分享链接',
'oauth.scope.places:read.label': '查看地点和地图数据',
'oauth.scope.places:read.description': '读取地点、每日分配、标签和分类',
'oauth.scope.places:write.label': '管理地点',
'oauth.scope.places:write.description': '创建、更新和删除地点、分配和标签',
'oauth.scope.atlas:read.label': '查看 Atlas',
'oauth.scope.atlas:read.description': '读取已访问国家、地区和心愿清单',
'oauth.scope.atlas:write.label': '管理 Atlas',
'oauth.scope.atlas:write.description': '标记已访问国家和地区,管理心愿清单',
'oauth.scope.packing:read.label': '查看行李清单',
'oauth.scope.packing:read.description': '读取行李物品、包袋和分类负责人',
'oauth.scope.packing:write.label': '管理行李清单',
'oauth.scope.packing:write.description': '添加、更新、删除、勾选和重新排列行李物品和包袋',
'oauth.scope.todos:read.label': '查看待办清单',
'oauth.scope.todos:read.description': '读取行程待办事项和分类负责人',
'oauth.scope.todos:write.label': '管理待办清单',
'oauth.scope.todos:write.description': '创建、更新、勾选、删除和重新排列待办事项',
'oauth.scope.budget:read.label': '查看预算',
'oauth.scope.budget:read.description': '读取预算条目和费用明细',
'oauth.scope.budget:write.label': '管理预算',
'oauth.scope.budget:write.description': '创建、更新和删除预算条目',
'oauth.scope.reservations:read.label': '查看预订',
'oauth.scope.reservations:read.description': '读取预订和住宿详情',
'oauth.scope.reservations:write.label': '管理预订',
'oauth.scope.reservations:write.description': '创建、更新、删除和重新排列预订',
'oauth.scope.collab:read.label': '查看协作',
'oauth.scope.collab:read.description': '读取协作笔记、投票和消息',
'oauth.scope.collab:write.label': '管理协作',
'oauth.scope.collab:write.description': '创建、更新和删除协作笔记、投票和消息',
'oauth.scope.notifications:read.label': '查看通知',
'oauth.scope.notifications:read.description': '读取应用内通知和未读数量',
'oauth.scope.notifications:write.label': '管理通知',
'oauth.scope.notifications:write.description': '将通知标记为已读并回复',
'oauth.scope.vacay:read.label': '查看假期计划',
'oauth.scope.vacay:read.description': '读取假期计划数据、条目和统计',
'oauth.scope.vacay:write.label': '管理假期计划',
'oauth.scope.vacay:write.description': '创建和管理假期条目、节假日和团队计划',
'oauth.scope.geo:read.label': '地图和地理编码',
'oauth.scope.geo:read.description': '搜索位置、解析地图 URL 和反向地理编码坐标',
'oauth.scope.weather:read.label': '天气预报',
'oauth.scope.weather:read.description': '获取行程地点和日期的天气预报',
} }
export default zh export default zh
+711 -18
View File
@@ -8,6 +8,8 @@ const zhTw: Record<string, string> = {
'common.loading': '載入中...', 'common.loading': '載入中...',
'common.import': '匯入', 'common.import': '匯入',
'common.error': '錯誤', 'common.error': '錯誤',
'common.unknownError': '未知錯誤',
'common.tooManyAttempts': '嘗試次數過多,請稍後再試。',
'common.back': '返回', 'common.back': '返回',
'common.all': '全部', 'common.all': '全部',
'common.close': '關閉', 'common.close': '關閉',
@@ -27,6 +29,12 @@ const zhTw: Record<string, string> = {
'common.password': '密碼', 'common.password': '密碼',
'common.saving': '儲存中...', 'common.saving': '儲存中...',
'common.saved': '已儲存', 'common.saved': '已儲存',
'common.expand': '展開',
'common.collapse': '折疊',
'trips.memberRemoved': '{username} 已移除',
'trips.memberRemoveError': '移除失敗',
'trips.memberAdded': '{username} 已新增',
'trips.memberAddError': '新增失敗',
'trips.reminder': '提醒', 'trips.reminder': '提醒',
'trips.reminderNone': '無', 'trips.reminderNone': '無',
'trips.reminderDay': '天', 'trips.reminderDay': '天',
@@ -113,6 +121,8 @@ const zhTw: Record<string, string> = {
'dashboard.tripDescriptionPlaceholder': '這次旅行是關於什麼的?', 'dashboard.tripDescriptionPlaceholder': '這次旅行是關於什麼的?',
'dashboard.startDate': '開始日期', 'dashboard.startDate': '開始日期',
'dashboard.endDate': '結束日期', 'dashboard.endDate': '結束日期',
'dashboard.dayCount': '天數',
'dashboard.dayCountHint': '未設定旅行日期時,要規劃的天數。',
'dashboard.noDateHint': '未設定日期——將預設建立 7 天。你可以隨時修改。', 'dashboard.noDateHint': '未設定日期——將預設建立 7 天。你可以隨時修改。',
'dashboard.coverImage': '封面圖片', 'dashboard.coverImage': '封面圖片',
'dashboard.addCoverImage': '新增封面圖片', 'dashboard.addCoverImage': '新增封面圖片',
@@ -123,10 +133,18 @@ const zhTw: Record<string, string> = {
'dashboard.coverRemoveError': '移除失敗', 'dashboard.coverRemoveError': '移除失敗',
'dashboard.titleRequired': '標題為必填項', 'dashboard.titleRequired': '標題為必填項',
'dashboard.endDateError': '結束日期必須晚於開始日期', 'dashboard.endDateError': '結束日期必須晚於開始日期',
'dashboard.dayCount': '天數',
'dashboard.dayCountHint': '未設定旅行日期時的規劃天數。',
// Settings // Settings
'settings.title': '設定', 'settings.title': '設定',
'settings.subtitle': '配置你的個人設定', 'settings.subtitle': '配置你的個人設定',
'settings.tabs.display': '顯示',
'settings.tabs.map': '地圖',
'settings.tabs.notifications': '通知',
'settings.tabs.integrations': '整合',
'settings.tabs.account': '帳戶',
'settings.tabs.about': '關於',
'settings.map': '地圖', 'settings.map': '地圖',
'settings.mapTemplate': '地圖模板', 'settings.mapTemplate': '地圖模板',
'settings.mapTemplatePlaceholder.select': '選擇模板...', 'settings.mapTemplatePlaceholder.select': '選擇模板...',
@@ -163,6 +181,19 @@ const zhTw: Record<string, string> = {
'settings.notifyCollabMessage': '聊天訊息 (Collab)', 'settings.notifyCollabMessage': '聊天訊息 (Collab)',
'settings.notifyPackingTagged': '行李清單:分配', 'settings.notifyPackingTagged': '行李清單:分配',
'settings.notifyWebhook': 'Webhook 通知', 'settings.notifyWebhook': 'Webhook 通知',
'settings.notifyVersionAvailable': '有新版本可用',
'settings.notificationPreferences.email': '電子郵件',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': '應用程式內',
'settings.notificationPreferences.noChannels': '未配置通知渠道。請聯絡管理員設定電子郵件或 Webhook 通知。',
'settings.webhookUrl.label': 'Webhook URL',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': '輸入您的 Discord、Slack 或自訂 Webhook URL 以接收通知。',
'settings.webhookUrl.save': '儲存',
'settings.webhookUrl.saved': 'Webhook URL 已儲存',
'settings.webhookUrl.test': '測試',
'settings.webhookUrl.testSuccess': '測試 Webhook 傳送成功',
'settings.webhookUrl.testFailed': '測試 Webhook 傳送失敗',
'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。', 'settings.notificationsDisabled': '通知尚未配置。請聯絡管理員啟用電子郵件或 Webhook 通知。',
'settings.notificationsActive': '活躍頻道', 'settings.notificationsActive': '活躍頻道',
'settings.notificationsManagedByAdmin': '通知事件由管理員配置。', 'settings.notificationsManagedByAdmin': '通知事件由管理員配置。',
@@ -171,18 +202,26 @@ const zhTw: Record<string, string> = {
'admin.notifications.none': '已停用', 'admin.notifications.none': '已停用',
'admin.notifications.email': '電子郵件 (SMTP)', 'admin.notifications.email': '電子郵件 (SMTP)',
'admin.notifications.webhook': 'Webhook', 'admin.notifications.webhook': 'Webhook',
'admin.notifications.events': '通知事件',
'admin.notifications.eventsHint': '選擇哪些事件為所有使用者觸發通知。',
'admin.notifications.configureFirst': '請先在下方配置 SMTP 或 Webhook,然後啟用事件。',
'admin.notifications.save': '儲存通知設定', 'admin.notifications.save': '儲存通知設定',
'admin.notifications.saved': '通知設定已儲存', 'admin.notifications.saved': '通知設定已儲存',
'admin.notifications.testWebhook': '傳送測試 Webhook', 'admin.notifications.testWebhook': '傳送測試 Webhook',
'admin.notifications.testWebhookSuccess': '測試 Webhook 傳送成功', 'admin.notifications.testWebhookSuccess': '測試 Webhook 傳送成功',
'admin.notifications.testWebhookFailed': '測試 Webhook 傳送失敗', 'admin.notifications.testWebhookFailed': '測試 Webhook 傳送失敗',
'admin.notifications.emailPanel.title': '電子郵件 (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': '應用程式內通知',
'admin.notifications.inappPanel.hint': '應用程式內通知始終啟用,無法全域性停用。',
'admin.notifications.adminWebhookPanel.title': '管理員 Webhook',
'admin.notifications.adminWebhookPanel.hint': '此 Webhook 專用於管理員通知(例如版本提醒)。它與每位使用者的 Webhook 分開,設定後始終會觸發。',
'admin.notifications.adminWebhookPanel.saved': '管理員 Webhook URL 已儲存',
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 傳送失敗',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '配置 URL 後,管理員 Webhook 始終觸發',
'admin.notifications.adminNotificationsHint': '配置哪些渠道傳遞僅管理員通知(例如版本提醒)。',
'admin.smtp.title': '郵件與通知', 'admin.smtp.title': '郵件與通知',
'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。', 'admin.smtp.hint': '用於傳送電子郵件通知的 SMTP 配置。',
'admin.smtp.testButton': '傳送測試郵件', 'admin.smtp.testButton': '傳送測試郵件',
'admin.webhook.hint': '向外部 Webhook 傳送通知(Discord、Slack 等)。', 'admin.webhook.hint': '允許使用者配置自己的 Webhook URL 以接收通知(Discord、Slack 等)。',
'admin.smtp.testSuccess': '測試郵件傳送成功', 'admin.smtp.testSuccess': '測試郵件傳送成功',
'admin.smtp.testFailed': '測試郵件傳送失敗', 'admin.smtp.testFailed': '測試郵件傳送失敗',
'dayplan.icsTooltip': '匯出日曆 (ICS)', 'dayplan.icsTooltip': '匯出日曆 (ICS)',
@@ -220,6 +259,7 @@ const zhTw: Record<string, string> = {
'settings.mcp.endpoint': 'MCP 端點', 'settings.mcp.endpoint': 'MCP 端點',
'settings.mcp.clientConfig': '客戶端配置', 'settings.mcp.clientConfig': '客戶端配置',
'settings.mcp.clientConfigHint': '將 <your_token> 替換為下方列表中的 API 令牌。npx 的路徑可能需要根據您的系統進行調整(例如 Windows 上為 C:\\PROGRA~1\\nodejs\\npx.cmd)。', 'settings.mcp.clientConfigHint': '將 <your_token> 替換為下方列表中的 API 令牌。npx 的路徑可能需要根據您的系統進行調整(例如 Windows 上為 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
'settings.mcp.clientConfigHintOAuth': '將 <your_client_id> 和 <your_client_secret> 替換為上方建立的 OAuth 2.1 客戶端所顯示的憑據。首次連線時,mcp-remote 將開啟瀏覽器完成授權。npx 的路徑可能需要根據您的系統進行調整(例如 Windows 上為 C:\\PROGRA~1\\nodejs\\npx.cmd)。',
'settings.mcp.copy': '複製', 'settings.mcp.copy': '複製',
'settings.mcp.copied': '已複製!', 'settings.mcp.copied': '已複製!',
'settings.mcp.apiTokens': 'API 令牌', 'settings.mcp.apiTokens': 'API 令牌',
@@ -241,8 +281,58 @@ const zhTw: Record<string, string> = {
'settings.mcp.toast.createError': '建立令牌失敗', 'settings.mcp.toast.createError': '建立令牌失敗',
'settings.mcp.toast.deleted': '令牌已刪除', 'settings.mcp.toast.deleted': '令牌已刪除',
'settings.mcp.toast.deleteError': '刪除令牌失敗', 'settings.mcp.toast.deleteError': '刪除令牌失敗',
'settings.mcp.apiTokensDeprecated': 'API 金鑰已棄用,將於未來版本中移除。請改用 OAuth 2.1 客戶端。',
'settings.oauth.clients': 'OAuth 2.1 客戶端',
'settings.oauth.clientsHint': '註冊 OAuth 2.1 客戶端,讓第三方 MCP 應用程式(Claude Web、Cursor 等)無需靜態金鑰即可連線。',
'settings.oauth.createClient': '新增客戶端',
'settings.oauth.noClients': '尚無已註冊的 OAuth 客戶端。',
'settings.oauth.clientId': '客戶端 ID',
'settings.oauth.clientSecret': '客戶端密鑰',
'settings.oauth.deleteClient': '刪除客戶端',
'settings.oauth.deleteClientMessage': '此客戶端及所有活躍工作階段將被永久刪除。任何使用此客戶端的應用程式將立即失去存取權限。',
'settings.oauth.rotateSecret': '輪換密鑰',
'settings.oauth.rotateSecretMessage': '將產生新的客戶端密鑰,所有現有工作階段將立即失效。請在關閉此對話框前更新您的應用程式。',
'settings.oauth.rotateSecretConfirm': '輪換',
'settings.oauth.rotateSecretConfirming': '輪換中…',
'settings.oauth.rotateSecretDoneTitle': '已產生新密鑰',
'settings.oauth.rotateSecretDoneWarning': '此密鑰僅顯示一次。請立即複製並更新您的應用程式——所有先前的工作階段已失效。',
'settings.oauth.activeSessions': '活躍的 OAuth 工作階段',
'settings.oauth.sessionScopes': '授權範圍',
'settings.oauth.sessionExpires': '到期時間',
'settings.oauth.revoke': '撤銷',
'settings.oauth.revokeSession': '撤銷工作階段',
'settings.oauth.revokeSessionMessage': '這將立即撤銷此 OAuth 工作階段的存取權限。',
'settings.oauth.modal.createTitle': '註冊 OAuth 客戶端',
'settings.oauth.modal.presets': '快速預設',
'settings.oauth.modal.clientName': '應用程式名稱',
'settings.oauth.modal.clientNamePlaceholder': '例如 Claude Web、我的 MCP 應用程式',
'settings.oauth.modal.redirectUris': '重新導向 URI',
'settings.oauth.modal.redirectUrisPlaceholder': 'https://your-app.com/callback\nhttps://your-app.com/auth',
'settings.oauth.modal.redirectUrisHint': '每行一個 URI。需要 HTTPSlocalhost 除外)。需要完全符合。',
'settings.oauth.modal.scopes': '允許的授權範圍',
'settings.oauth.modal.scopesHint': 'list_trips 和 get_trip_summary 始終可用——不需要授權範圍。它們可幫助 AI 找到所需的行程 ID。',
'settings.oauth.modal.selectAll': '全選',
'settings.oauth.modal.deselectAll': '取消全選',
'settings.oauth.modal.creating': '註冊中…',
'settings.oauth.modal.create': '註冊客戶端',
'settings.oauth.modal.createdTitle': '客戶端已註冊',
'settings.oauth.modal.createdWarning': '客戶端密鑰僅顯示一次。請立即複製——無法恢復。',
'settings.oauth.toast.createError': '註冊 OAuth 客戶端失敗',
'settings.oauth.toast.deleted': 'OAuth 客戶端已刪除',
'settings.oauth.toast.deleteError': '刪除 OAuth 客戶端失敗',
'settings.oauth.toast.revoked': '工作階段已撤銷',
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
'settings.oauth.toast.rotateError': '輪換客戶端密鑰失敗',
'settings.account': '賬戶', 'settings.account': '賬戶',
'settings.about': '關於', 'settings.about': '關於',
'settings.about.reportBug': '回報錯誤',
'settings.about.reportBugHint': '發現問題?告訴我們',
'settings.about.featureRequest': '功能建議',
'settings.about.featureRequestHint': '建議新功能',
'settings.about.wikiHint': '文件與指南',
'settings.about.description': 'TREK 是一款自架旅遊規劃器,幫助您從最初構想到最後回憶,整理每次旅行。日程規劃、預算、行李清單、照片及更多功能——全部集中在您自己的伺服器上。',
'settings.about.madeWith': '以',
'settings.about.madeBy': '由 Maurice 及不斷成長的開源社群製作。',
'settings.username': '使用者名稱', 'settings.username': '使用者名稱',
'settings.email': '郵箱', 'settings.email': '郵箱',
'settings.role': '角色', 'settings.role': '角色',
@@ -348,6 +438,10 @@ const zhTw: Record<string, string> = {
'login.mfaHint': '開啟 Google Authenticator、Authy 或其他 TOTP 應用。', 'login.mfaHint': '開啟 Google Authenticator、Authy 或其他 TOTP 應用。',
'login.mfaBack': '← 返回登入', 'login.mfaBack': '← 返回登入',
'login.mfaVerify': '驗證', 'login.mfaVerify': '驗證',
'login.invalidInviteLink': '邀請連結無效或已過期',
'login.oidcFailed': 'OIDC 登入失敗',
'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元',
'login.oidc.tokenFailed': '認證失敗。', 'login.oidc.tokenFailed': '認證失敗。',
'login.oidc.invalidState': '會話無效,請重試。', 'login.oidc.invalidState': '會話無效,請重試。',
'login.demoFailed': '演示登入失敗', 'login.demoFailed': '演示登入失敗',
@@ -385,6 +479,7 @@ const zhTw: Record<string, string> = {
'admin.tabs.categories': '分類', 'admin.tabs.categories': '分類',
'admin.tabs.backup': '備份', 'admin.tabs.backup': '備份',
'admin.tabs.audit': '審計日誌', 'admin.tabs.audit': '審計日誌',
'admin.tabs.notifications': '通知',
'admin.stats.users': '使用者', 'admin.stats.users': '使用者',
'admin.stats.trips': '旅行', 'admin.stats.trips': '旅行',
'admin.stats.places': '地點', 'admin.stats.places': '地點',
@@ -434,6 +529,17 @@ const zhTw: Record<string, string> = {
'admin.tabs.settings': '設定', 'admin.tabs.settings': '設定',
'admin.allowRegistration': '允許註冊', 'admin.allowRegistration': '允許註冊',
'admin.allowRegistrationHint': '新使用者可以自行註冊', 'admin.allowRegistrationHint': '新使用者可以自行註冊',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': '要求雙因素身份驗證(2FA', 'admin.requireMfa': '要求雙因素身份驗證(2FA',
'admin.requireMfaHint': '未啟用 2FA 的使用者必須先完成設定中的配置才能使用應用。', 'admin.requireMfaHint': '未啟用 2FA 的使用者必須先完成設定中的配置才能使用應用。',
'admin.apiKeys': 'API 金鑰', 'admin.apiKeys': 'API 金鑰',
@@ -531,9 +637,10 @@ const zhTw: Record<string, string> = {
'admin.weather.locationHint': '天氣基於每天中第一個有座標的地點。如果當天沒有分配地點,則使用地點列表中的任意地點作為參考。', 'admin.weather.locationHint': '天氣基於每天中第一個有座標的地點。如果當天沒有分配地點,則使用地點列表中的任意地點作為參考。',
// MCP Tokens // MCP Tokens
'admin.tabs.mcpTokens': 'MCP 令牌', 'admin.tabs.mcpTokens': 'MCP 存取',
'admin.mcpTokens.title': 'MCP 令牌', 'admin.mcpTokens.title': 'MCP 存取',
'admin.mcpTokens.subtitle': '管理所有使用者的 API 令牌', 'admin.mcpTokens.subtitle': '管理所有使用者的 OAuth 工作階段和 API 令牌',
'admin.mcpTokens.sectionTitle': 'API 令牌',
'admin.mcpTokens.owner': '所有者', 'admin.mcpTokens.owner': '所有者',
'admin.mcpTokens.tokenName': '令牌名稱', 'admin.mcpTokens.tokenName': '令牌名稱',
'admin.mcpTokens.created': '建立時間', 'admin.mcpTokens.created': '建立時間',
@@ -545,6 +652,17 @@ const zhTw: Record<string, string> = {
'admin.mcpTokens.deleteSuccess': '令牌已刪除', 'admin.mcpTokens.deleteSuccess': '令牌已刪除',
'admin.mcpTokens.deleteError': '刪除令牌失敗', 'admin.mcpTokens.deleteError': '刪除令牌失敗',
'admin.mcpTokens.loadError': '載入令牌失敗', 'admin.mcpTokens.loadError': '載入令牌失敗',
'admin.oauthSessions.sectionTitle': 'OAuth 工作階段',
'admin.oauthSessions.clientName': '客戶端',
'admin.oauthSessions.owner': '所有者',
'admin.oauthSessions.scopes': '權限範圍',
'admin.oauthSessions.created': '建立時間',
'admin.oauthSessions.empty': '目前沒有活躍的 OAuth 工作階段',
'admin.oauthSessions.revokeTitle': '撤銷工作階段',
'admin.oauthSessions.revokeMessage': '此 OAuth 工作階段將立即被撤銷。客戶端將失去 MCP 存取權限。',
'admin.oauthSessions.revokeSuccess': '工作階段已撤銷',
'admin.oauthSessions.revokeError': '撤銷工作階段失敗',
'admin.oauthSessions.loadError': '載入 OAuth 工作階段失敗',
// GitHub // GitHub
'admin.tabs.github': 'GitHub', 'admin.tabs.github': 'GitHub',
@@ -640,6 +758,8 @@ const zhTw: Record<string, string> = {
'vacay.companyHolidays': '公司假日', 'vacay.companyHolidays': '公司假日',
'vacay.companyHolidaysHint': '允許標記公司統一休假日', 'vacay.companyHolidaysHint': '允許標記公司統一休假日',
'vacay.companyHolidaysNoDeduct': '公司假日不計入年假天數。', 'vacay.companyHolidaysNoDeduct': '公司假日不計入年假天數。',
'vacay.weekStart': '每週開始於',
'vacay.weekStartHint': '選擇日曆週從週一還是週日開始',
'vacay.carryOver': '結轉', 'vacay.carryOver': '結轉',
'vacay.carryOverHint': '自動將剩餘年假天數結轉到下一年', 'vacay.carryOverHint': '自動將剩餘年假天數結轉到下一年',
'vacay.sharing': '共享', 'vacay.sharing': '共享',
@@ -724,8 +844,10 @@ const zhTw: Record<string, string> = {
'atlas.unmark': '移除', 'atlas.unmark': '移除',
'atlas.confirmMark': '將此國家標記為已訪問?', 'atlas.confirmMark': '將此國家標記為已訪問?',
'atlas.confirmUnmark': '從已訪問列表中移除此國家?', 'atlas.confirmUnmark': '從已訪問列表中移除此國家?',
'atlas.confirmUnmarkRegion': '從已訪問列表中移除此地區?',
'atlas.markVisited': '標記為已訪問', 'atlas.markVisited': '標記為已訪問',
'atlas.markVisitedHint': '將此國家新增到已訪問列表', 'atlas.markVisitedHint': '將此國家新增到已訪問列表',
'atlas.markRegionVisitedHint': '將此地區新增到已訪問列表',
'atlas.addToBucket': '新增到心願單', 'atlas.addToBucket': '新增到心願單',
'atlas.addPoi': '新增地點', 'atlas.addPoi': '新增地點',
'atlas.searchCountry': '搜尋國家...', 'atlas.searchCountry': '搜尋國家...',
@@ -739,6 +861,8 @@ const zhTw: Record<string, string> = {
'trip.tabs.reservationsShort': '預訂', 'trip.tabs.reservationsShort': '預訂',
'trip.tabs.packing': '行李清單', 'trip.tabs.packing': '行李清單',
'trip.tabs.packingShort': '行李', 'trip.tabs.packingShort': '行李',
'trip.tabs.lists': '清單',
'trip.tabs.listsShort': '清單',
'trip.tabs.budget': '預算', 'trip.tabs.budget': '預算',
'trip.tabs.files': '檔案', 'trip.tabs.files': '檔案',
'trip.loading': '載入旅行中...', 'trip.loading': '載入旅行中...',
@@ -850,6 +974,7 @@ const zhTw: Record<string, string> = {
'inspector.files': '檔案', 'inspector.files': '檔案',
'inspector.filesCount': '{count} 個檔案', 'inspector.filesCount': '{count} 個檔案',
'inspector.removeFromDay': '從當天移除', 'inspector.removeFromDay': '從當天移除',
'inspector.remove': '刪除',
'inspector.addToDay': '新增到當天', 'inspector.addToDay': '新增到當天',
'inspector.confirmedRes': '已確認預訂', 'inspector.confirmedRes': '已確認預訂',
'inspector.pendingRes': '待確認預訂', 'inspector.pendingRes': '待確認預訂',
@@ -938,6 +1063,32 @@ const zhTw: Record<string, string> = {
'reservations.linkAssignment': '關聯日程分配', 'reservations.linkAssignment': '關聯日程分配',
'reservations.pickAssignment': '從計劃中選擇一個分配...', 'reservations.pickAssignment': '從計劃中選擇一個分配...',
'reservations.noAssignment': '無關聯(獨立)', 'reservations.noAssignment': '無關聯(獨立)',
'reservations.price': '價格',
'reservations.budgetCategory': '預算分類',
'reservations.budgetCategoryPlaceholder': '如:交通、住宿',
'reservations.budgetCategoryAuto': '自動(依預訂類型)',
'reservations.budgetHint': '儲存時將自動建立預算條目。',
'reservations.departureDate': '出發日期',
'reservations.arrivalDate': '到達日期',
'reservations.departureTime': '出發時間',
'reservations.arrivalTime': '到達時間',
'reservations.pickupDate': '取車日期',
'reservations.returnDate': '還車日期',
'reservations.pickupTime': '取車時間',
'reservations.returnTime': '還車時間',
'reservations.endDate': '結束日期',
'reservations.meta.departureTimezone': '出發時區',
'reservations.meta.arrivalTimezone': '到達時區',
'reservations.span.departure': '出發',
'reservations.span.arrival': '到達',
'reservations.span.inTransit': '途中',
'reservations.span.pickup': '取車',
'reservations.span.return': '還車',
'reservations.span.active': '進行中',
'reservations.span.start': '開始',
'reservations.span.end': '結束',
'reservations.span.ongoing': '進行中',
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
// Budget // Budget
'budget.title': '預算', 'budget.title': '預算',
@@ -964,6 +1115,7 @@ const zhTw: Record<string, string> = {
'budget.totalBudget': '總預算', 'budget.totalBudget': '總預算',
'budget.byCategory': '按分類', 'budget.byCategory': '按分類',
'budget.editTooltip': '點選編輯', 'budget.editTooltip': '點選編輯',
'budget.linkedToReservation': '已連結至預訂——請在那裡編輯名稱',
'budget.confirm.deleteCategory': '確定刪除分類「{name}」及其 {count} 個條目?', 'budget.confirm.deleteCategory': '確定刪除分類「{name}」及其 {count} 個條目?',
'budget.deleteCategory': '刪除分類', 'budget.deleteCategory': '刪除分類',
'budget.perPerson': '人均', 'budget.perPerson': '人均',
@@ -976,6 +1128,9 @@ const zhTw: Record<string, string> = {
// Files // Files
'files.title': '檔案', 'files.title': '檔案',
'files.pageTitle': '檔案與文件',
'files.subtitle': '{trip} 的 {count} 個檔案',
'files.downloadPdf': '下載 PDF',
'files.count': '{count} 個檔案', 'files.count': '{count} 個檔案',
'files.countSingular': '1 個檔案', 'files.countSingular': '1 個檔案',
'files.uploaded': '已上傳 {count} 個', 'files.uploaded': '已上傳 {count} 個',
@@ -1062,7 +1217,9 @@ const zhTw: Record<string, string> = {
'packing.template': '模板', 'packing.template': '模板',
'packing.templateApplied': '已從模板新增 {count} 個物品', 'packing.templateApplied': '已從模板新增 {count} 個物品',
'packing.templateError': '應用模板失敗', 'packing.templateError': '應用模板失敗',
'packing.assignUser': '分配使用者', 'packing.saveAsTemplate': '儲存為範本',
'packing.templateName': '範本名稱',
'packing.templateSaved': '行李清單已儲存為範本',
'packing.noMembers': '無成員', 'packing.noMembers': '無成員',
'packing.bags': '行李', 'packing.bags': '行李',
'packing.noBag': '未分配', 'packing.noBag': '未分配',
@@ -1219,6 +1376,13 @@ const zhTw: Record<string, string> = {
'backup.keep.forever': '永久保留', 'backup.keep.forever': '永久保留',
// Photos // Photos
'photos.title': '照片',
'photos.subtitle': '{trip} 的 {count} 張照片',
'photos.dropHere': '將照片拖放至此...',
'photos.dropHereActive': '將照片拖放至此',
'photos.captionForAll': '標題(所有)',
'photos.captionPlaceholder': '可選標題...',
'photos.addCaption': '新增標題...',
'photos.allDays': '所有天', 'photos.allDays': '所有天',
'photos.noPhotos': '暫無照片', 'photos.noPhotos': '暫無照片',
'photos.uploadHint': '上傳你的旅行照片', 'photos.uploadHint': '上傳你的旅行照片',
@@ -1226,6 +1390,12 @@ const zhTw: Record<string, string> = {
'photos.linkPlace': '關聯地點', 'photos.linkPlace': '關聯地點',
'photos.noPlace': '無地點', 'photos.noPlace': '無地點',
'photos.uploadN': '上傳 {n} 張照片', 'photos.uploadN': '上傳 {n} 張照片',
'photos.linkDay': '關聯天數',
'photos.noDay': '無天數',
'photos.dayLabel': '第 {number} 天',
'photos.photoSelected': '張照片已選擇',
'photos.photosSelected': '張照片已選擇',
'photos.fileTypeHint': 'JPG, PNG, WebP · 最大 10 MB · 最多 30 張照片',
// Backup restore modal // Backup restore modal
'backup.restoreConfirmTitle': '恢復備份?', 'backup.restoreConfirmTitle': '恢復備份?',
@@ -1252,6 +1422,7 @@ const zhTw: Record<string, string> = {
'planner.routeCalculated': '路線已計算', 'planner.routeCalculated': '路線已計算',
'planner.routeCalcFailed': '無法計算路線', 'planner.routeCalcFailed': '無法計算路線',
'planner.routeError': '路線計算錯誤', 'planner.routeError': '路線計算錯誤',
'planner.icsExportFailed': 'ICS 匯出失敗',
'planner.routeOptimized': '路線已最佳化', 'planner.routeOptimized': '路線已最佳化',
'planner.reservationUpdated': '預訂已更新', 'planner.reservationUpdated': '預訂已更新',
'planner.reservationAdded': '預訂已新增', 'planner.reservationAdded': '預訂已新增',
@@ -1335,11 +1506,12 @@ const zhTw: Record<string, string> = {
// Memories / Immich // Memories / Immich
'memories.title': '照片', 'memories.title': '照片',
'memories.notConnected': 'Immich 未連線', 'memories.notConnected': '{provider_name} 未連線',
'memories.notConnectedHint': '在設定中連線您的 Immich 例項以在此檢視旅行照片。', 'memories.notConnectedHint': '在設定中連線您的 {provider_name} 例項以在此旅行中新增照片。',
'memories.notConnectedMultipleHint': '在設定中連線以下任一照片提供商:{provider_names} 以在此旅行中新增照片。',
'memories.noDates': '為旅行新增日期以載入照片。', 'memories.noDates': '為旅行新增日期以載入照片。',
'memories.noPhotos': '未找到照片', 'memories.noPhotos': '未找到照片',
'memories.noPhotosHint': 'Immich 中未找到此旅行日期範圍內的照片。', 'memories.noPhotosHint': '{provider_name} 中未找到此旅行日期範圍內的照片。',
'memories.photosFound': '張照片', 'memories.photosFound': '張照片',
'memories.fromOthers': '來自他人', 'memories.fromOthers': '來自他人',
'memories.sharePhotos': '分享照片', 'memories.sharePhotos': '分享照片',
@@ -1347,26 +1519,35 @@ const zhTw: Record<string, string> = {
'memories.reviewTitle': '審查您的照片', 'memories.reviewTitle': '審查您的照片',
'memories.reviewHint': '點選照片以將其從分享中排除。', 'memories.reviewHint': '點選照片以將其從分享中排除。',
'memories.shareCount': '分享 {count} 張照片', 'memories.shareCount': '分享 {count} 張照片',
'memories.immichUrl': 'Immich 伺服器地址', 'memories.providerUrl': '伺服器 URL',
'memories.immichApiKey': 'API 金鑰', 'memories.providerApiKey': 'API 金鑰',
'memories.providerUsername': '使用者名稱',
'memories.providerPassword': '密碼',
'memories.providerOTP': 'MFA 驗證碼(如已啟用)',
'memories.skipSSLVerification': '跳過 SSL 憑證驗證',
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
'memories.testConnection': '測試連線', 'memories.testConnection': '測試連線',
'memories.testFirst': '請先測試連線', 'memories.testFirst': '請先測試連線',
'memories.connected': '已連線', 'memories.connected': '已連線',
'memories.disconnected': '未連線', 'memories.disconnected': '未連線',
'memories.connectionSuccess': '已連線到 Immich', 'memories.connectionSuccess': '已連線到 {provider_name}',
'memories.connectionError': '無法連線到 Immich', 'memories.connectionError': '無法連線到 {provider_name}',
'memories.saved': 'Immich 設定已儲存', 'memories.saved': '{provider_name} 設定已儲存',
'memories.providerDisconnectedBanner': '您與 {provider_name} 的連線已中斷。請在設定中重新連線以查看照片。',
'memories.saveError': '無法儲存 {provider_name} 設定',
'memories.oldest': '最早優先', 'memories.oldest': '最早優先',
'memories.newest': '最新優先', 'memories.newest': '最新優先',
'memories.allLocations': '所有地點', 'memories.allLocations': '所有地點',
'memories.addPhotos': '新增照片', 'memories.addPhotos': '新增照片',
'memories.linkAlbum': '關聯相簿', 'memories.linkAlbum': '關聯相簿',
'memories.selectAlbum': '選擇 Immich 相簿', 'memories.selectAlbum': '選擇 {provider_name} 相簿',
'memories.selectAlbumMultiple': '選擇相簿',
'memories.noAlbums': '未找到相簿', 'memories.noAlbums': '未找到相簿',
'memories.syncAlbum': '同步相簿', 'memories.syncAlbum': '同步相簿',
'memories.unlinkAlbum': '取消關聯', 'memories.unlinkAlbum': '取消關聯',
'memories.photos': '張照片', 'memories.photos': '張照片',
'memories.selectPhotos': '從 Immich 選擇照片', 'memories.selectPhotos': '從 {provider_name} 選擇照片',
'memories.selectPhotosMultiple': '選擇照片',
'memories.selectHint': '點選照片以選擇。', 'memories.selectHint': '點選照片以選擇。',
'memories.selected': '已選擇', 'memories.selected': '已選擇',
'memories.addSelected': '新增 {count} 張照片', 'memories.addSelected': '新增 {count} 張照片',
@@ -1511,6 +1692,40 @@ const zhTw: Record<string, string> = {
'undo.importGoogleList': 'Google 地圖匯入', 'undo.importGoogleList': 'Google 地圖匯入',
'undo.importNaverList': 'Naver 地圖匯入', 'undo.importNaverList': 'Naver 地圖匯入',
// Todo
'todo.subtab.packing': '行李清單',
'todo.subtab.todo': '待辦事項',
'todo.completed': '已完成',
'todo.filter.all': '全部',
'todo.filter.open': '未完成',
'todo.filter.done': '已完成',
'todo.uncategorized': '未分類',
'todo.namePlaceholder': '任務名稱',
'todo.descriptionPlaceholder': '說明(可選)',
'todo.unassigned': '未指派',
'todo.noCategory': '無分類',
'todo.hasDescription': '有說明',
'todo.addItem': '新增任務...',
'todo.newCategory': '分類名稱',
'todo.addCategory': '新增分類',
'todo.newItem': '新任務',
'todo.empty': '尚無任務。新增任務以開始!',
'todo.filter.my': '我的任務',
'todo.filter.overdue': '已逾期',
'todo.sidebar.tasks': '任務',
'todo.sidebar.categories': '分類',
'todo.detail.title': '任務',
'todo.detail.description': '說明',
'todo.detail.category': '分類',
'todo.detail.dueDate': '到期日',
'todo.detail.assignedTo': '指派給',
'todo.detail.delete': '刪除',
'todo.detail.save': '儲存變更',
'todo.sortByPrio': '優先順序',
'todo.detail.priority': '優先順序',
'todo.detail.noPriority': '無',
'todo.detail.create': '建立任務',
// Notifications // Notifications
'notifications.title': '通知', 'notifications.title': '通知',
'notifications.markAllRead': '全部標為已讀', 'notifications.markAllRead': '全部標為已讀',
@@ -1524,6 +1739,8 @@ const zhTw: Record<string, string> = {
'notifications.markUnread': '標為未讀', 'notifications.markUnread': '標為未讀',
'notifications.delete': '刪除', 'notifications.delete': '刪除',
'notifications.system': '系統', 'notifications.system': '系統',
'notifications.synologySessionCleared.title': 'Synology Photos 已斷線',
'notifications.synologySessionCleared.text': '您的伺服器或帳號已更改 — 請前往設定重新測試連線。',
'memories.error.loadAlbums': '載入相簿失敗', 'memories.error.loadAlbums': '載入相簿失敗',
'memories.error.linkAlbum': '關聯相簿失敗', 'memories.error.linkAlbum': '關聯相簿失敗',
'memories.error.unlinkAlbum': '取消關聯相簿失敗', 'memories.error.unlinkAlbum': '取消關聯相簿失敗',
@@ -1547,6 +1764,482 @@ const zhTw: Record<string, string> = {
'notifications.test.adminText': '{actor} 向所有管理員傳送了測試通知。', 'notifications.test.adminText': '{actor} 向所有管理員傳送了測試通知。',
'notifications.test.tripTitle': '{actor} 在您的行程中發帖', 'notifications.test.tripTitle': '{actor} 在您的行程中發帖',
'notifications.test.tripText': '行程"{trip}"的測試通知。', 'notifications.test.tripText': '行程"{trip}"的測試通知。',
// Journey, Dashboard, Nav, DayPlan
'common.justNow': '剛剛',
'common.hoursAgo': '{count}小時前',
'common.daysAgo': '{count}天前',
'budget.linkedToReservation': '已關聯預訂 — 請在預訂中編輯名稱',
'packing.saveAsTemplate': '儲存為範本',
'packing.templateName': '範本名稱',
'packing.templateSaved': '打包清單已儲存為範本',
'memories.notConnectedMultipleHint': '在設定中連接以下任一照片服務:{provider_names},以便為此旅行新增照片。',
'memories.providerUrl': '伺服器位址',
'memories.providerApiKey': 'API 金鑰',
'memories.providerUsername': '使用者名稱',
'memories.providerPassword': '密碼',
'memories.saveError': '無法儲存 {provider_name} 設定',
'memories.saveRouteNotConfigured': '此提供商未設定儲存路由',
'memories.testRouteNotConfigured': '此提供商未設定測試路由',
'memories.fillRequiredFields': '請填寫所有必填欄位',
'memories.selectAlbumMultiple': '選擇相簿',
'memories.selectPhotosMultiple': '選擇照片',
'journey.title': '旅程',
'journey.subtitle': '即時記錄你的旅行',
'journey.new': '新建旅程',
'journey.create': '建立',
'journey.titlePlaceholder': '你要去哪裡?',
'journey.empty': '還沒有旅程',
'journey.emptyHint': '開始記錄你的下一次旅行',
'journey.deleted': '旅程已刪除',
'journey.createError': '無法建立旅程',
'journey.deleteError': '無法刪除旅程',
'journey.deleteConfirmTitle': '刪除',
'journey.deleteConfirmMessage': '刪除「{title}」?此操作無法復原。',
'journey.deleteConfirmGeneric': '確定要刪除嗎?',
'journey.notFound': '未找到旅程',
'journey.photos': '照片',
'journey.timelineEmpty': '還沒有行程',
'journey.timelineEmptyHint': '新增一個打卡或寫一篇日誌開始記錄',
'journey.status.draft': '草稿',
'journey.status.active': '進行中',
'journey.status.completed': '已完成',
'journey.status.upcoming': '即將開始',
'journey.checkin.add': '打卡',
'journey.checkin.namePlaceholder': '地點名稱',
'journey.checkin.notesPlaceholder': '備註(可選)',
'journey.checkin.save': '儲存',
'journey.checkin.error': '無法儲存打卡',
'journey.entry.add': '日誌',
'journey.entry.edit': '編輯條目',
'journey.entry.titlePlaceholder': '標題(可選)',
'journey.entry.bodyPlaceholder': '今天發生了什麼?',
'journey.entry.save': '儲存',
'journey.entry.error': '無法儲存條目',
'journey.photo.add': '照片',
'journey.photo.uploadError': '上傳失敗',
'journey.share.share': '分享',
'journey.share.public': '公開',
'journey.share.linkCopied': '公開連結已複製',
'journey.share.disabled': '已關閉公開分享',
'journey.editor.titlePlaceholder': '給這個瞬間起個名字...',
'journey.editor.bodyPlaceholder': '講述這一天的故事...',
'journey.editor.placePlaceholder': '地點(可選)',
'journey.editor.tagsPlaceholder': '標籤:隱藏寶藏、最佳美食、值得再訪...',
'journey.visibility.private': '私密',
'journey.visibility.shared': '共享',
'journey.visibility.public': '公開',
'journey.emptyState.title': '你的故事從這裡開始',
'journey.emptyState.subtitle': '在某個地方打卡或寫下你的第一篇日誌',
'journey.frontpage.subtitle': '將旅行變成永遠不會忘記的故事',
'journey.frontpage.createJourney': '建立旅程',
'journey.frontpage.activeJourney': '進行中的旅程',
'journey.frontpage.allJourneys': '所有旅程',
'journey.frontpage.journeys': '個旅程',
'journey.frontpage.createNew': '建立新旅程',
'journey.frontpage.createNewSub': '選擇旅行、寫故事、分享你的冒險',
'journey.frontpage.live': '即時',
'journey.frontpage.synced': '已同步',
'journey.frontpage.continueWriting': '繼續撰寫',
'journey.frontpage.updated': '更新於 {time}',
'journey.frontpage.suggestionLabel': '旅行剛結束',
'journey.frontpage.suggestionText': '將 <strong>{title}</strong> 變成一段旅程',
'journey.frontpage.dismiss': '忽略',
'journey.frontpage.journeyName': '旅程名稱',
'journey.frontpage.namePlaceholder': '例如 東南亞 2026',
'journey.frontpage.selectTrips': '選擇旅行',
'journey.frontpage.tripsSelected': '個旅行已選擇',
'journey.frontpage.trips': '個旅行',
'journey.frontpage.placesImported': '個地點將被匯入',
'journey.frontpage.places': '個地點',
'journey.detail.backToJourney': '返回旅程',
'journey.detail.syncedWithTrips': '已與旅行同步',
'journey.detail.addEntry': '新增條目',
'journey.detail.newEntry': '新建條目',
'journey.detail.editEntry': '編輯條目',
'journey.detail.noEntries': '還沒有條目',
'journey.detail.noEntriesHint': '新增一個旅行以產生骨架條目',
'journey.detail.noPhotos': '還沒有照片',
'journey.detail.noPhotosHint': '上傳照片到條目或瀏覽你的 Immich/Synology 相簿',
'journey.detail.journeyStats': '旅程統計',
'journey.detail.syncedTrips': '已同步的旅行',
'journey.detail.noTripsLinked': '尚未關聯旅行',
'journey.detail.contributors': '貢獻者',
'journey.detail.readMore': '閱讀更多',
'journey.detail.prosCons': '優缺點',
'journey.stats.days': '天',
'journey.stats.cities': '城市',
'journey.stats.entries': '條目',
'journey.stats.photos': '照片',
'journey.stats.places': '地點',
'journey.verdict.lovedIt': '非常喜歡',
'journey.verdict.couldBeBetter': '有待改進',
'journey.synced.places': '個地點',
'journey.synced.synced': '已同步',
'journey.editor.uploadPhotos': '上傳照片',
'journey.editor.fromGallery': '從相簿選擇',
'journey.editor.allPhotosAdded': '所有照片已新增',
'journey.editor.writeStory': '寫下你的故事...',
'journey.editor.prosCons': '優缺點',
'journey.editor.pros': '優點',
'journey.editor.cons': '缺點',
'journey.editor.proPlaceholder': '好的方面...',
'journey.editor.conPlaceholder': '不好的方面...',
'journey.editor.addAnother': '再新增一個',
'journey.editor.date': '日期',
'journey.editor.location': '地點',
'journey.editor.searchLocation': '搜尋地點...',
'journey.editor.mood': '心情',
'journey.editor.weather': '天氣',
'journey.editor.photoFirst': '第1張',
'journey.editor.makeFirst': '設為第1張',
'journey.mood.amazing': '太棒了',
'journey.mood.good': '不錯',
'journey.mood.neutral': '一般',
'journey.mood.rough': '糟糕',
'journey.weather.sunny': '晴天',
'journey.weather.partly': '多雲',
'journey.weather.cloudy': '陰天',
'journey.weather.rainy': '雨天',
'journey.weather.stormy': '暴風雨',
'journey.weather.cold': '雪天',
'journey.trips.linkTrip': '關聯旅行',
'journey.trips.searchTrip': '搜尋旅行',
'journey.trips.searchPlaceholder': '旅行名稱或目的地...',
'journey.trips.noTripsAvailable': '沒有可用的旅行',
'journey.trips.link': '關聯',
'journey.trips.tripLinked': '旅行已關聯',
'journey.trips.linkFailed': '關聯旅行失敗',
'journey.trips.addTrip': '新增旅行',
'journey.trips.unlinkTrip': '取消關聯旅行',
'journey.trips.unlinkMessage': '取消關聯「{title}」?此旅行中所有已同步的條目和照片將被永久刪除。此操作無法復原。',
'journey.trips.unlink': '取消關聯',
'journey.trips.tripUnlinked': '旅行已取消關聯',
'journey.trips.unlinkFailed': '取消關聯失敗',
'journey.trips.noTripsLinkedSettings': '未關聯旅行',
'journey.contributors.invite': '邀請貢獻者',
'journey.contributors.searchUser': '搜尋使用者',
'journey.contributors.searchPlaceholder': '使用者名稱或郵箱...',
'journey.contributors.noUsers': '未找到使用者',
'journey.contributors.role': '角色',
'journey.contributors.added': '貢獻者已新增',
'journey.contributors.addFailed': '新增貢獻者失敗',
'journey.share.publicShare': '公開分享',
'journey.share.createLink': '建立分享連結',
'journey.share.linkCreated': '分享連結已建立',
'journey.share.createFailed': '建立連結失敗',
'journey.share.copy': '複製',
'journey.share.copied': '已複製!',
'journey.share.timeline': '時間線',
'journey.share.gallery': '圖庫',
'journey.share.map': '地圖',
'journey.share.removeLink': '移除分享連結',
'journey.share.linkDeleted': '分享連結已刪除',
'journey.share.deleteFailed': '刪除失敗',
'journey.share.updateFailed': '更新失敗',
'journey.settings.title': '旅程設定',
'journey.settings.coverImage': '封面圖片',
'journey.settings.changeCover': '更換封面',
'journey.settings.addCover': '新增封面圖片',
'journey.settings.name': '名稱',
'journey.settings.subtitle': '副標題',
'journey.settings.subtitlePlaceholder': '例如 泰國、越南和柬埔寨',
'journey.settings.delete': '刪除',
'journey.settings.deleteJourney': '刪除旅程',
'journey.settings.deleteMessage': '刪除「{title}」?所有條目和照片將遺失。',
'journey.settings.saved': '設定已儲存',
'journey.settings.saveFailed': '儲存失敗',
'journey.settings.coverUpdated': '封面已更新',
'journey.settings.coverFailed': '上傳失敗',
'journey.settings.failedToDelete': '刪除失敗',
'journey.entries.deleteTitle': '刪除條目',
'journey.photosUploaded': '{count} 張照片已上傳',
'journey.photosAdded': '{count} 張照片已新增',
'journey.public.notFound': '未找到',
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
'journey.public.readOnly': '唯讀 · 公開旅程',
'journey.public.tagline': '旅行資源與探索工具包',
'journey.public.sharedVia': '分享自',
'journey.public.madeWith': '由',
'journey.pdf.journeyBook': '旅程手冊',
'journey.pdf.madeWith': '由 TREK 製作',
'journey.pdf.day': '第',
'journey.pdf.theEnd': '終',
'journey.pdf.saveAsPdf': '儲存為 PDF',
'journey.pdf.pages': '頁',
'dashboard.greeting.morning': '早安,',
'dashboard.greeting.afternoon': '午安,',
'dashboard.greeting.evening': '晚安,',
'dashboard.mobile.liveNow': '進行中',
'dashboard.mobile.tripProgress': '旅行進度',
'dashboard.mobile.daysLeft': '還剩 {count} 天',
'dashboard.mobile.places': '地點',
'dashboard.mobile.buddies': '旅伴',
'dashboard.mobile.newTrip': '新建旅行',
'dashboard.mobile.currency': '貨幣',
'dashboard.mobile.timezone': '時區',
'dashboard.mobile.upcomingTrips': '即將到來的旅行',
'dashboard.mobile.yourTrips': '我的旅行',
'dashboard.mobile.trips': '個旅行',
'dashboard.mobile.starts': '出發',
'dashboard.mobile.duration': '時長',
'dashboard.mobile.day': '天',
'dashboard.mobile.days': '天',
'dashboard.mobile.ongoing': '進行中',
'dashboard.mobile.startsToday': '今天出發',
'dashboard.mobile.tomorrow': '明天',
'dashboard.mobile.inDays': '{count} 天後',
'dashboard.mobile.inMonths': '{count} 個月後',
'dashboard.mobile.completed': '已完成',
'dashboard.mobile.currencyConverter': '匯率轉換',
'nav.profile': '個人資料',
'nav.bottomSettings': '設定',
'nav.bottomAdmin': '管理設定',
'nav.bottomLogout': '退出登入',
'nav.bottomAdminBadge': '管理員',
'dayplan.mobile.addPlace': '新增地點',
'dayplan.mobile.searchPlaces': '搜尋地點...',
'dayplan.mobile.allAssigned': '所有地點已分配',
'dayplan.mobile.noMatch': '無匹配',
'dayplan.mobile.createNew': '建立新地點',
'admin.addons.catalog.journey.name': '旅程',
'admin.addons.catalog.journey.description': '旅行追蹤與旅行日誌,包含打卡、照片和每日故事',
'dashboard.dayCount': '天數',
'dashboard.dayCountHint': '未設定旅行日期時規劃的天數。',
'settings.tabs.display': '顯示',
'settings.tabs.map': '地圖',
'settings.tabs.notifications': '通知',
'settings.tabs.integrations': '整合',
'settings.tabs.account': '帳戶',
'settings.tabs.about': '關於',
'settings.notifyVersionAvailable': '有新版本可用',
'settings.notificationPreferences.email': '電子郵件',
'settings.notificationPreferences.webhook': 'Webhook',
'settings.notificationPreferences.inapp': '應用內',
'settings.notificationPreferences.noChannels': '尚未設定通知管道。請聯繫管理員設定電子郵件或 Webhook 通知。',
'settings.webhookUrl.label': 'Webhook 網址',
'settings.webhookUrl.placeholder': 'https://discord.com/api/webhooks/...',
'settings.webhookUrl.hint': '輸入你的 Discord、Slack 或自訂 Webhook 網址以接收通知。',
'settings.webhookUrl.save': '儲存',
'settings.webhookUrl.saved': 'Webhook 網址已儲存',
'settings.webhookUrl.test': '測試',
'settings.webhookUrl.testSuccess': '測試 Webhook 傳送成功',
'settings.webhookUrl.testFailed': '測試 Webhook 失敗',
'admin.notifications.emailPanel.title': '電子郵件 (SMTP)',
'admin.notifications.webhookPanel.title': 'Webhook',
'admin.notifications.inappPanel.title': '應用內',
'admin.notifications.inappPanel.hint': '應用內通知始終處於啟用狀態,無法全域停用。',
'admin.notifications.adminWebhookPanel.title': '管理員 Webhook',
'admin.notifications.adminWebhookPanel.hint': '此 Webhook 僅用於管理員通知(例如版本更新提醒)。它與每位使用者的 Webhook 分開,設定後將始終觸發。',
'admin.notifications.adminWebhookPanel.saved': '管理員 Webhook 網址已儲存',
'admin.notifications.adminWebhookPanel.testSuccess': '測試 Webhook 傳送成功',
'admin.notifications.adminWebhookPanel.testFailed': '測試 Webhook 失敗',
'admin.notifications.adminWebhookPanel.alwaysOnHint': '設定網址後管理員 Webhook 將始終觸發',
'admin.notifications.adminNotificationsHint': '設定哪些管道傳送僅限管理員的通知(例如版本更新提醒)。',
'settings.about.reportBug': '回報錯誤',
'settings.about.reportBugHint': '發現問題?請告訴我們',
'settings.about.featureRequest': '功能建議',
'settings.about.featureRequestHint': '提出新功能建議',
'settings.about.wikiHint': '文件與指南',
'settings.about.description': 'TREK 是一個自架式旅行規劃工具,幫助你從第一個想法到最後一個回憶來組織旅行。日程規劃、預算、打包清單、照片等等——全部集中在一處,在你自己的伺服器上。',
'settings.about.madeWith': '以',
'settings.about.madeBy': '由 Maurice 和不斷壯大的開源社群製作。',
'admin.tabs.notifications': '通知',
'atlas.confirmUnmarkRegion': '將此地區從已造訪清單中移除?',
'atlas.markRegionVisitedHint': '將此地區新增至已造訪清單',
'trip.tabs.lists': '清單',
'trip.tabs.listsShort': '清單',
'reservations.price': '價格',
'reservations.budgetCategory': '預算類別',
'reservations.budgetCategoryPlaceholder': '例如 交通、住宿',
'reservations.budgetCategoryAuto': '自動(依預訂類型)',
'reservations.budgetHint': '儲存時將自動建立一筆預算項目。',
'reservations.departureDate': '出發日期',
'reservations.arrivalDate': '抵達日期',
'reservations.departureTime': '出發時間',
'reservations.arrivalTime': '抵達時間',
'reservations.pickupDate': '取車日期',
'reservations.returnDate': '還車日期',
'reservations.pickupTime': '取車時間',
'reservations.returnTime': '還車時間',
'reservations.endDate': '結束日期',
'reservations.meta.departureTimezone': '出發時區',
'reservations.meta.arrivalTimezone': '抵達時區',
'reservations.span.departure': '出發',
'reservations.span.arrival': '抵達',
'reservations.span.inTransit': '運輸中',
'reservations.span.pickup': '取車',
'reservations.span.return': '還車',
'reservations.span.active': '使用中',
'reservations.span.start': '開始',
'reservations.span.end': '結束',
'reservations.span.ongoing': '進行中',
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
'notifications.versionAvailable.button': '查看詳情',
'todo.subtab.packing': '打包清單',
'todo.subtab.todo': '待辦事項',
'todo.completed': '已完成',
'todo.filter.all': '全部',
'todo.filter.open': '未完成',
'todo.filter.done': '已完成',
'todo.uncategorized': '未分類',
'todo.namePlaceholder': '任務名稱',
'todo.descriptionPlaceholder': '描述(可選)',
'todo.unassigned': '未指派',
'todo.noCategory': '無類別',
'todo.hasDescription': '有描述',
'todo.addItem': '新增任務...',
'todo.newCategory': '類別名稱',
'todo.addCategory': '新增類別',
'todo.newItem': '新任務',
'todo.empty': '還沒有任務。新增一個任務開始吧!',
'todo.filter.my': '我的任務',
'todo.filter.overdue': '已逾期',
'todo.sidebar.tasks': '任務',
'todo.sidebar.categories': '類別',
'todo.detail.title': '任務',
'todo.detail.description': '描述',
'todo.detail.category': '類別',
'todo.detail.dueDate': '截止日期',
'todo.detail.assignedTo': '指派給',
'todo.detail.delete': '刪除',
'todo.detail.save': '儲存變更',
'todo.sortByPrio': '優先順序',
'todo.detail.priority': '優先順序',
'todo.detail.noPriority': '無',
'todo.detail.create': '建立任務',
'notif.test.title': '[測試] 通知',
'notif.test.simple.text': '這是一則簡單的測試通知。',
'notif.test.boolean.text': '你是否接受這則測試通知?',
'notif.test.navigate.text': '點擊下方前往儀表板。',
'notif.trip_invite.title': '旅行邀請',
'notif.trip_invite.text': '{actor} 邀請你加入 {trip}',
'notif.booking_change.title': '預訂已更新',
'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂',
'notif.trip_reminder.title': '旅行提醒',
'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!',
'notif.vacay_invite.title': 'Vacay Fusion 邀請',
'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫',
'notif.photos_shared.title': '照片已分享',
'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 張照片',
'notif.collab_message.title': '新訊息',
'notif.collab_message.text': '{actor} 在 {trip} 中傳送了一則訊息',
'notif.packing_tagged.title': '打包指派',
'notif.packing_tagged.text': '{actor} 在 {trip} 中將 {category} 指派給你',
'notif.version_available.title': '有新版本可用',
'notif.version_available.text': 'TREK {version} 現已推出',
'notif.action.view_trip': '查看旅行',
'notif.action.view_collab': '查看訊息',
'notif.action.view_packing': '查看打包',
'notif.action.view_photos': '查看照片',
'notif.action.view_vacay': '查看 Vacay',
'notif.action.view_admin': '前往管理',
'notifications.versionAvailable.title': '有可用更新',
'notifications.versionAvailable.text': 'TREK {version} 現已推出。',
'notifications.versionAvailable.button': '查看詳情',
// Notifications — dev test events
'notif.test.title': '[測試] 通知',
'notif.test.simple.text': '這是一條簡單的測試通知。',
'notif.test.boolean.text': '您接受此測試通知嗎?',
'notif.test.navigate.text': '點選下方前往儀表板。',
// Notifications
'notif.trip_invite.title': '行程邀請',
'notif.trip_invite.text': '{actor} 邀請您加入 {trip}',
'notif.booking_change.title': '預訂已更新',
'notif.booking_change.text': '{actor} 已更新 {trip} 中的預訂',
'notif.trip_reminder.title': '行程提醒',
'notif.trip_reminder.text': '您的行程 {trip} 即將開始!',
'notif.vacay_invite.title': 'Vacay 合併邀請',
'notif.vacay_invite.text': '{actor} 邀請您合併假期計畫',
'notif.photos_shared.title': '已分享照片',
'notif.photos_shared.text': '{actor} 在 {trip} 中分享了 {count} 張照片',
'notif.collab_message.title': '新訊息',
'notif.collab_message.text': '{actor} 在 {trip} 中傳送了訊息',
'notif.packing_tagged.title': '行李指派',
'notif.packing_tagged.text': '{actor} 在 {trip} 中將您指派至 {category}',
'notif.version_available.title': '有新版本可用',
'notif.version_available.text': 'TREK {version} 現已推出',
'notif.action.view_trip': '查看行程',
'notif.action.view_collab': '查看訊息',
'notif.action.view_packing': '查看行李',
'notif.action.view_photos': '查看照片',
'notif.action.view_vacay': '查看 Vacay',
'notif.action.view_admin': '前往管理員',
'notif.action.view': '查看',
'notif.action.accept': '接受',
'notif.action.decline': '拒絕',
'notif.generic.title': '通知',
'notif.generic.text': '你有一則新通知',
'notif.dev.unknown_event.title': '[DEV] 未知事件',
'notif.dev.unknown_event.text': '事件類型「{event}」未在 EVENT_NOTIFICATION_CONFIG 中註冊',
// OAuth scope groups
'oauth.scope.group.trips': '行程',
'oauth.scope.group.places': '地點',
'oauth.scope.group.atlas': 'Atlas',
'oauth.scope.group.packing': '行李',
'oauth.scope.group.todos': '待辦事項',
'oauth.scope.group.budget': '預算',
'oauth.scope.group.reservations': '預訂',
'oauth.scope.group.collab': '協作',
'oauth.scope.group.notifications': '通知',
'oauth.scope.group.vacay': '假期',
'oauth.scope.group.geo': 'Geo',
'oauth.scope.group.weather': '天氣',
// OAuth scope labels & descriptions
'oauth.scope.trips:read.label': '檢視行程與旅遊計畫',
'oauth.scope.trips:read.description': '讀取行程、天數、每日筆記及成員',
'oauth.scope.trips:write.label': '編輯行程與旅遊計畫',
'oauth.scope.trips:write.description': '建立及更新行程、天數、筆記並管理成員',
'oauth.scope.trips:delete.label': '刪除行程',
'oauth.scope.trips:delete.description': '永久刪除整個行程——此操作無法復原',
'oauth.scope.trips:share.label': '管理分享連結',
'oauth.scope.trips:share.description': '建立、更新及撤銷行程的公開分享連結',
'oauth.scope.places:read.label': '檢視地點與地圖資料',
'oauth.scope.places:read.description': '讀取地點、每日指派、標籤及類別',
'oauth.scope.places:write.label': '管理地點',
'oauth.scope.places:write.description': '建立、更新及刪除地點、指派及標籤',
'oauth.scope.atlas:read.label': '檢視 Atlas',
'oauth.scope.atlas:read.description': '讀取已造訪的國家、地區及願望清單',
'oauth.scope.atlas:write.label': '管理 Atlas',
'oauth.scope.atlas:write.description': '標記已造訪的國家及地區,管理願望清單',
'oauth.scope.packing:read.label': '檢視行李清單',
'oauth.scope.packing:read.description': '讀取行李物品、行李袋及類別負責人',
'oauth.scope.packing:write.label': '管理行李清單',
'oauth.scope.packing:write.description': '新增、更新、刪除、勾選及重新排序行李物品和行李袋',
'oauth.scope.todos:read.label': '檢視待辦清單',
'oauth.scope.todos:read.description': '讀取行程待辦事項及類別負責人',
'oauth.scope.todos:write.label': '管理待辦清單',
'oauth.scope.todos:write.description': '建立、更新、勾選、刪除及重新排序待辦事項',
'oauth.scope.budget:read.label': '檢視預算',
'oauth.scope.budget:read.description': '讀取預算項目及費用明細',
'oauth.scope.budget:write.label': '管理預算',
'oauth.scope.budget:write.description': '建立、更新及刪除預算項目',
'oauth.scope.reservations:read.label': '檢視預訂',
'oauth.scope.reservations:read.description': '讀取預訂及住宿詳情',
'oauth.scope.reservations:write.label': '管理預訂',
'oauth.scope.reservations:write.description': '建立、更新、刪除及重新排序預訂',
'oauth.scope.collab:read.label': '檢視協作',
'oauth.scope.collab:read.description': '讀取協作筆記、投票及訊息',
'oauth.scope.collab:write.label': '管理協作',
'oauth.scope.collab:write.description': '建立、更新及刪除協作筆記、投票及訊息',
'oauth.scope.notifications:read.label': '檢視通知',
'oauth.scope.notifications:read.description': '讀取應用程式通知及未讀數量',
'oauth.scope.notifications:write.label': '管理通知',
'oauth.scope.notifications:write.description': '將通知標為已讀並回覆',
'oauth.scope.vacay:read.label': '檢視假期計畫',
'oauth.scope.vacay:read.description': '讀取假期計畫資料、項目及統計',
'oauth.scope.vacay:write.label': '管理假期計畫',
'oauth.scope.vacay:write.description': '建立及管理假期項目、節假日及團隊計畫',
'oauth.scope.geo:read.label': '地圖與地理編碼',
'oauth.scope.geo:read.description': '搜尋地點、解析地圖 URL 及反向地理編碼坐標',
'oauth.scope.weather:read.label': '天氣預報',
'oauth.scope.weather:read.description': '取得行程地點及日期的天氣預報',
} }
export default zhTw export default zhTw

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