Compare commits

..

1 Commits

354 changed files with 7387 additions and 17534 deletions
-4
View File
@@ -30,7 +30,3 @@ sonar-project.properties
server/tests/ server/tests/
server/vitest.config.ts server/vitest.config.ts
server/reset-admin.js server/reset-admin.js
**/*.test.ts
wiki/
scripts/
charts/
-3
View File
@@ -12,8 +12,6 @@ body:
required: true required: true
- label: I am running the latest available version of TREK - label: I am running the latest available version of TREK
required: true required: true
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
required: true
- type: input - type: input
id: version id: version
@@ -62,7 +60,6 @@ body:
- Docker (standalone) - Docker (standalone)
- Kubernetes / Helm - Kubernetes / Helm
- Unraid template - Unraid template
- Proxmox Community Script
- Sources - Sources
- Other - Other
validations: validations:
+1 -1
View File
@@ -15,7 +15,7 @@
## Checklist ## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing) - [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) - [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)* - [ ] This PR targets the `dev` branch, not `main`
- [ ] I have tested my changes locally - [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works - [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] I have updated documentation if needed - [ ] I have updated documentation if needed
@@ -26,36 +26,9 @@ jobs:
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) { for (const pull of pulls) {
const hasBypass = pull.labels.some(l => l.name === 'bypass-branch-check');
if (hasBypass) continue;
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch'); const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue; if (!hasLabel) continue;
// Wiki-only PRs are exempt — clear label and skip
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
const createdAt = new Date(pull.created_at); const createdAt = new Date(pull.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
-5
View File
@@ -6,11 +6,6 @@ on:
paths-ignore: paths-ignore:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
- 'wiki/**'
- '.github/workflows/**'
- '.github/ISSUE_TEMPLATE/**'
- '.github/FUNDING.yml'
- '.github/PULL_REQUEST_TEMPLATE.md'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump: bump:
@@ -21,39 +21,6 @@ jobs:
const labels = context.payload.pull_request.labels.map(l => l.name); const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number; const prNumber = context.payload.pull_request.number;
// bypass-branch-check label skips all enforcement
if (labels.includes('bypass-branch-check')) {
console.log('bypass-branch-check label present, skipping enforcement.');
return;
}
// Wiki-only PRs are exempt from branch enforcement
const files = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
page,
});
files.push(...data);
if (data.length < 100) break;
}
const allWiki = files.length > 0 && files.every(f => f.filename.startsWith('wiki/'));
if (allWiki) {
console.log('All changed files are under wiki/ — skipping enforcement.');
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// If the base was fixed, remove the label and let it through // If the base was fixed, remove the label and let it through
if (base !== 'main') { if (base !== 'main') {
if (labels.includes('wrong-base-branch')) { if (labels.includes('wrong-base-branch')) {
-37
View File
@@ -1,37 +0,0 @@
name: Security Scan
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
pull-requests: write
jobs:
scout:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
tags: trek:scan
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/scout-action@v1
with:
command: cves
image: trek:scan
only-severities: critical,high
exit-code: true
-26
View File
@@ -1,26 +0,0 @@
name: Deploy Wiki
on:
push:
branches: [main]
paths:
- 'wiki/**'
- '.github/workflows/wiki.yml'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: wiki-deploy
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish to GitHub wiki
uses: Andrew-Chen-Wang/github-wiki-action@v5
with:
strategy: clone
+1 -6
View File
@@ -3,8 +3,6 @@ node_modules/
# Build output # Build output
client/dist/ client/dist/
server/public/*
!server/public/.gitkeep
# Generated PWA icons (built from SVG via prebuild) # Generated PWA icons (built from SVG via prebuild)
client/public/icons/*.png client/public/icons/*.png
@@ -60,7 +58,4 @@ coverage
*.tgz *.tgz
.scannerwork .scannerwork
test-data test-data
.run
.full-review
+2 -2
View File
@@ -4,10 +4,10 @@ Thanks for your interest in contributing! Please read these guidelines before op
## Ground Rules ## Ground Rules
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed 1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/P7TUxHJs). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors 2. **One change per PR** — Keep it focused. Don't bundle unrelated fixes or refactors
3. **No breaking changes** — Backwards compatibility is non-negotiable 3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch 4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups 5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed 6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR 7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
+4 -7
View File
@@ -1,5 +1,5 @@
# Stage 1: Build React client # Stage 1: Build React client
FROM node:24-alpine AS client-builder FROM node:22-alpine AS client-builder
WORKDIR /app/client WORKDIR /app/client
COPY client/package*.json ./ COPY client/package*.json ./
RUN npm ci RUN npm ci
@@ -7,7 +7,7 @@ COPY client/ ./
RUN npm run build RUN npm run build
# Stage 2: Production server # Stage 2: Production server
FROM node:24-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
@@ -15,16 +15,13 @@ WORKDIR /app
COPY server/package*.json ./ COPY server/package*.json ./
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \ RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
npm ci --production && \ npm ci --production && \
rm package-lock.json && \ apk del python3 make g++
apk del python3 make g++ && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
COPY server/ ./ COPY server/ ./
COPY --from=client-builder /app/client/dist ./public COPY --from=client-builder /app/client/dist ./public
COPY --from=client-builder /app/client/public/fonts ./public/fonts COPY --from=client-builder /app/client/public/fonts ./public/fonts
RUN rm -f package-lock.json && \ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \ mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
chown -R node:node /app chown -R node:node /app
+14 -31
View File
@@ -6,29 +6,19 @@
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" /> <img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
</picture> </picture>
<br /> ### Your trips. Your plan. Your server.
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
</picture>
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in. A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
<br /> <br />
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a> <a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a>
&nbsp; &nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge&logo=docker&logoColor=white" /></a>
&nbsp; &nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a> <a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></a>
&nbsp; &nbsp;
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a> <a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></a>
<br />
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
&nbsp;
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br /> <br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a> <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a> <a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
@@ -41,7 +31,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<div align="center"> <div align="center">
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" /> <img src="https://github.com/mauriceboe/test/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
</div> </div>
@@ -127,23 +117,19 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### 🧩 Addons (admin-toggleable) #### 🧩 Addons (admin-toggleable)
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
- **Budget** — expense tracker with splits, pie chart, multi-currency
- **Documents** — file attachments on trips, places, and reservations
- **Collab** — chat, notes, polls, day-by-day attendance
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking - **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI - **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods - **Collab** — chat, notes, polls, day-by-day attendance
- **Naver List Import** — one-click import from shared Naver Maps lists - **Journey** — magazine-style travel journal with entries, photos, maps, moods
- **MCP** — expose TREK to AI assistants via OAuth 2.1 - **Dashboard widgets** — currency converter and timezone clocks
</td> </td>
<td width="50%" valign="top"> <td width="50%" valign="top">
#### 🤖 AI / MCP #### 🤖 AI / MCP
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources - **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
- **Granular scopes** — 27 OAuth scopes across 13 permission groups - **Granular scopes** — 24 OAuth scopes across 13 permission groups
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited - **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview` - **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on - **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
@@ -156,7 +142,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
#### ⚙️ Admin & customisation #### ⚙️ Admin & customisation
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar - **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID - **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history - **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates - **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
@@ -176,7 +162,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
``` ```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`). Open `http://localhost:3000`. The first user to register becomes admin.
<div align="center"> <div align="center">
@@ -342,8 +328,7 @@ server {
ssl_certificate /etc/ssl/fullchain.pem; ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem; ssl_certificate_key /etc/ssl/privkey.pem;
# 500 MB covers backup-restore uploads (capped at 500 MB server-side). client_max_body_size 50m;
client_max_body_size 500m;
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3000;
@@ -360,7 +345,6 @@ server {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_read_timeout 86400;
} }
} }
``` ```
@@ -400,7 +384,6 @@ Caddy handles TLS and WebSockets automatically.
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` | | `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto | | `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` | | `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
+1 -1
View File
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
If you discover a security vulnerability, please report it responsibly: If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue 1. **Do not** open a public issue
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch** 2. Email: **mauriceboe@icloud.com**
3. Include a description of the vulnerability and steps to reproduce 3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible. You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
-121
View File
@@ -1,121 +0,0 @@
# Trademark Policy
## Introduction
This is the TREK project's policy for the use of our trademarks. While TREK is
available under the GNU Affero General Public License v3.0 (AGPL-3.0), that
license does not include a license to use our trademarks.
This policy describes how you may use our trademarks. Our goal is to strike a
balance between: 1) our need to ensure that our trademarks remain reliable
indicators of the software we release; and 2) our community members' desire to
be full participants in the TREK project.
## Our trademarks
This policy covers the name "TREK" as well as any associated logos, trade dress,
goodwill, or designs (our "Marks").
## In general
Whenever you use our Marks, you must always do so in a way that does not mislead
anyone about exactly who is the source of the software. For example, you cannot
say you are distributing TREK when you're distributing a modified version of it,
because people would think they would be getting the same software that they
can get directly from us when they aren't. You also cannot use our Marks on
your website in a way that suggests that your website is an official TREK
website or that we endorse your website. But, if true, you can say you like
TREK, that you participate in the TREK community, that you are providing an
unmodified version of TREK, or that you wrote a guide describing how to use
TREK.
This fundamental requirement, that it is always clear to people what they are
getting and from whom, is reflected throughout this policy. It should also
serve as your guide if you are not sure about how you are using the Marks.
In addition:
* You may not use or register, in whole or in part, the Marks as part of your
own trademark, service mark, domain name, company name, trade name, product
name or service name.
* Trademark law does not allow your use of names or trademarks that are too
similar to ours. You therefore may not use an obvious variation of any of our
Marks or any phonetic equivalent, foreign language equivalent, takeoff, or
abbreviation for a similar or compatible product or service.
* You agree that you will not acquire any rights in the Marks and that any
goodwill generated by your use of the Marks and participation in our
community inures solely to our benefit.
## Distribution of unmodified source code or unmodified executable code we have compiled
When you redistribute an unmodified copy of TREK, you are not changing the
quality or nature of it. Therefore, you may retain the Marks we have placed on
the software to identify your redistribution. This kind of use only applies if
you are redistributing an official TREK distribution that has not been changed
in any way.
## Distribution of executable code that you have compiled, or modified code
You may use the word mark "TREK", but not any TREK logos, to truthfully
describe the origin of the software that you are providing, that is, that the
code you are distributing is a modification of TREK. You may say, for example,
that "this software is derived from the source code for TREK."
Of course, you can place your own trademarks or logos on versions of the
software to which you have made substantive modifications, because by modifying
the software, you have become the origin of that exact version. In that case,
you should not use our Marks.
However, you may use our Marks for the distribution of code (source or
executable) on the condition that any executable is built from an official TREK
source code release and that any modifications are limited to switching on or
off features already included in the software, translations into other
languages, and incorporating minor bug-fix patches. Use of our Marks on any
further modification is not permitted.
## Mobile wrappers, hosted instances, and forks
The following clarifications apply specifically to common ways TREK is
redistributed:
* **Self-hosted instances of unmodified TREK.** You may refer to your instance
as "a TREK instance" or "running TREK." You may not name the service itself
in a way that suggests it is the official TREK ("TREK Cloud," "TREK
Official," etc.).
* **Mobile wrappers (WebView shells, Capacitor apps, native apps) pointing at
TREK.** You may describe your app as "a mobile client for TREK" or "for use
with TREK." You may not publish it on app stores under the name "TREK" or a
confusingly similar name, and you may not use the TREK logo as the app icon
unless your wrapper distributes only an unmodified, official TREK instance
and you have obtained permission.
* **Forks of the TREK source code.** Forks that diverge from upstream must use
a different name. You may state that your fork is "based on TREK" or "a fork
of TREK," but the project name itself must be your own.
## Statements about your software's relation to TREK
You may use the word mark, but not TREK logos, to truthfully describe the
relationship between your software and ours. The word mark "TREK" should be
used after a verb or preposition that describes the relationship between your
software and ours. So you may say, for example, "Bob's app for TREK" but may
not say "Bob's TREK app." Some other examples that may work for you are:
* [Your software] uses TREK
* [Your software] is powered by TREK
* [Your software] runs on TREK
* [Your software] for use with TREK
* [Your software] for TREK
## Questions and permission requests
If you are not sure whether your intended use of the Marks is permitted under
this policy, or if you would like to request explicit permission for a use that
is not covered, please open an issue on the TREK GitHub repository or contact
the maintainers directly.
---
These guidelines are based on the
[Model Trademark Guidelines](http://www.modeltrademarkguidelines.org), used
under a
[Creative Commons Attribution 3.0 Unported license](https://creativecommons.org/licenses/by/3.0/deed.en_US).
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
CLIENT_DIR="$REPO_ROOT/client"
SERVER_DIR="$REPO_ROOT/server"
PUBLIC_DIR="$REPO_ROOT/server/public"
echo "==> Installing client dependencies"
cd "$CLIENT_DIR"
npm ci
echo "==> Building client"
npm run build
echo "==> Installing server dependencies"
cd "$SERVER_DIR"
npm ci
echo "==> Populating server/public"
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
echo "==> Done — server/public is ready"
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.19 version: 2.9.14
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.19" appVersion: "2.9.14"
-3
View File
@@ -22,9 +22,6 @@ data:
{{- if .Values.env.FORCE_HTTPS }} {{- if .Values.env.FORCE_HTTPS }}
FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }} FORCE_HTTPS: {{ .Values.env.FORCE_HTTPS | quote }}
{{- end }} {{- end }}
{{- if .Values.env.HSTS_INCLUDE_SUBDOMAINS }}
HSTS_INCLUDE_SUBDOMAINS: {{ .Values.env.HSTS_INCLUDE_SUBDOMAINS | quote }}
{{- end }}
{{- if .Values.env.COOKIE_SECURE }} {{- if .Values.env.COOKIE_SECURE }}
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }} COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
{{- end }} {{- end }}
-2
View File
@@ -30,8 +30,6 @@ env:
# 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"
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY. # Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
# HSTS_INCLUDE_SUBDOMAINS: "false"
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
# COOKIE_SECURE: "true" # COOKIE_SECURE: "true"
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. 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"
+1740 -366
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.19", "version": "2.9.14",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -18,7 +18,6 @@
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
+2 -2
View File
@@ -58,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
} }
if (!isAuthenticated) { if (!isAuthenticated) {
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash) const redirectParam = encodeURIComponent(location.pathname + location.search)
return <Navigate to={`/login?redirect=${redirectParam}`} replace /> return <Navigate to={`/login?redirect=${redirectParam}`} replace />
} }
@@ -218,7 +218,7 @@ export default function App() {
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} /> <Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
+62 -125
View File
@@ -1,6 +1,5 @@
import axios, { AxiosInstance } from 'axios' import axios, { AxiosInstance } from 'axios'
import { getSocketId } from './websocket' import { getSocketId } from './websocket'
import { isReachable, probeNow } from '../sync/connectivity'
import en from '../i18n/translations/en' import en from '../i18n/translations/en'
import br from '../i18n/translations/br' import br from '../i18n/translations/br'
import de from '../i18n/translations/de' import de from '../i18n/translations/de'
@@ -34,7 +33,6 @@ function translateRateLimit(): string {
export const apiClient: AxiosInstance = axios.create({ export const apiClient: AxiosInstance = axios.create({
baseURL: '/api', baseURL: '/api',
withCredentials: true, withCredentials: true,
timeout: 8000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -44,110 +42,55 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
// Request interceptor - add socket ID + idempotency key for mutating requests // Request interceptor - add socket ID + idempotency key for mutating requests
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config) => { (config) => {
const sid = getSocketId() const sid = getSocketId()
if (sid) { if (sid) {
config.headers['X-Socket-Id'] = sid config.headers['X-Socket-Id'] = sid
} }
// Attach a per-request idempotency key to all write operations so the // Attach a per-request idempotency key to all write operations so the
// server can deduplicate retried requests (e.g. network blips). // server can deduplicate retried requests (e.g. network blips).
// The mutation queue sets its own pre-generated key; skip if already set. // The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase() const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) { if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID() ? crypto.randomUUID()
: Math.random().toString(36).slice(2) : Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key config.headers['X-Idempotency-Key'] = key
} }
return config return config
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error)
) )
export function isAuthPublicPath(pathname: string): boolean { // Response interceptor - handle 401, 403 MFA, 429 rate limit
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
// Unregisters the SW before reloading so the navigation reaches the network.
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
async function unregisterSWAndReload(): Promise<void> {
try {
const reg = await navigator.serviceWorker?.getRegistration()
if (reg) await reg.unregister()
} catch { /* ignore */ }
window.location.reload()
}
// Response interceptor - handle 401, 403 MFA, 429 rate limit, proxy auth challenges
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => { (response) => response,
sessionStorage.removeItem('proxy_reauth_attempted') (error) => {
return response 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/') && !window.location.pathname.startsWith('/public/')) {
async (error) => { const currentPath = window.location.pathname + window.location.search
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
// as a CORS error with no response object. Probe the health endpoint to
// distinguish a proxy auth challenge from a genuine outage. If the server
// is reachable, a top-level reload lets the edge proxy run its auth flow.
if (!error.response && navigator.onLine) {
await probeNow()
// Both the original request and the health probe failed while the device
// has a network interface. This matches the proxy-auth-challenge pattern
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
// Guard with sessionStorage to prevent reload loops (server genuinely
// down would also land here, but only reloads once).
if (!isReachable()) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
} }
// Pangolin header-auth extended compatibility mode: returns 401 with an
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
// always application/json, so checking for text/html is unambiguous.
if (error.response?.status === 401) {
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
if (ct.includes('text/html')) {
const { pathname } = window.location
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
sessionStorage.setItem('proxy_reauth_attempted', '1')
await unregisterSWAndReload()
return Promise.reject(error)
}
}
}
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
const { pathname } = window.location
if (!isAuthPublicPath(pathname)) {
const currentPath = pathname + window.location.search + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
}
if (
error.response?.status === 403 &&
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
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)
} }
if (
error.response?.status === 403 &&
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
!window.location.pathname.startsWith('/settings')
) {
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)
}
) )
export const authApi = { export const authApi = {
@@ -192,7 +135,6 @@ export const oauthApi = {
state?: string state?: string
code_challenge: string code_challenge: string
code_challenge_method: string code_challenge_method: string
resource?: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data), }) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */ /** Submit user consent (approve or deny) */
@@ -204,13 +146,12 @@ export const oauthApi = {
code_challenge: string code_challenge: string
code_challenge_method: string code_challenge_method: string
approved: boolean approved: boolean
resource?: string
}) => apiClient.post('/oauth/authorize', body).then(r => r.data), }) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: { clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data), list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) => create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
apiClient.post('/oauth/clients', data).then(r => r.data), apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).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), delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
}, },
@@ -267,11 +208,11 @@ export const placesApi = {
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
}, },
importGoogleList: (tripId: number | string, url: string) => importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
importNaverList: (tripId: number | string, url: string) => importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) => bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data), apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
} }
export const assignmentsApi = { export const assignmentsApi = {
@@ -365,7 +306,7 @@ export const adminApi = {
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data), createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data), deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
auditLog: (params?: { limit?: number; offset?: number }) => auditLog: (params?: { limit?: number; offset?: number }) =>
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), oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
@@ -374,7 +315,7 @@ export const adminApi = {
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),
sendTestNotification: (data: Record<string, unknown>) => sendTestNotification: (data: Record<string, unknown>) =>
apiClient.post('/admin/dev/test-notification', data).then(r => r.data), apiClient.post('/admin/dev/test-notification', data).then(r => r.data),
getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data), getNotificationPreferences: () => apiClient.get('/admin/notification-preferences').then(r => r.data),
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data), updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data), getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
@@ -408,13 +349,9 @@ export const journeyApi = {
// Photos // Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).then(r => r.data), linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
unlinkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/entries/${entryId}/photos/${journeyPhotoId}`).then(r => r.data),
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => apiClient.delete(`/journeys/${journeyId}/gallery/${journeyPhotoId}`).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).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), deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
@@ -439,7 +376,7 @@ export const journeyApi = {
export const mapsApi = { export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data), search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) => autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data), apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data), reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
@@ -495,7 +432,7 @@ export const weatherApi = {
export const configApi = { export const configApi = {
getPublicConfig: (): Promise<{ defaultLanguage: string }> => getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
apiClient.get('/config').then(r => r.data), apiClient.get('/config').then(r => r.data),
} }
export const settingsApi = { export const settingsApi = {
@@ -581,21 +518,21 @@ export const notificationsApi = {
export const inAppNotificationsApi = { export const inAppNotificationsApi = {
list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) => list: (params?: { limit?: number; offset?: number; unread_only?: boolean }) =>
apiClient.get('/notifications/in-app', { params }).then(r => r.data), apiClient.get('/notifications/in-app', { params }).then(r => r.data),
unreadCount: () => unreadCount: () =>
apiClient.get('/notifications/in-app/unread-count').then(r => r.data), apiClient.get('/notifications/in-app/unread-count').then(r => r.data),
markRead: (id: number) => markRead: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data), apiClient.put(`/notifications/in-app/${id}/read`).then(r => r.data),
markUnread: (id: number) => markUnread: (id: number) =>
apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data), apiClient.put(`/notifications/in-app/${id}/unread`).then(r => r.data),
markAllRead: () => markAllRead: () =>
apiClient.put('/notifications/in-app/read-all').then(r => r.data), apiClient.put('/notifications/in-app/read-all').then(r => r.data),
delete: (id: number) => delete: (id: number) =>
apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data), apiClient.delete(`/notifications/in-app/${id}`).then(r => r.data),
deleteAll: () => deleteAll: () =>
apiClient.delete('/notifications/in-app/all').then(r => r.data), apiClient.delete('/notifications/in-app/all').then(r => r.data),
respond: (id: number, response: 'positive' | 'negative') => respond: (id: number, response: 'positive' | 'negative') =>
apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data), apiClient.post(`/notifications/in-app/${id}/respond`, { response }).then(r => r.data),
} }
export default apiClient export default apiClient
+27 -28
View File
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
} }
const handleRenameCategory = async (oldName, newName) => { const handleRenameCategory = async (oldName, newName) => {
if (!newName.trim() || newName.trim() === oldName) return if (!newName.trim() || newName.trim() === oldName) return
const items = grouped.get(oldName) || [] const items = grouped[oldName] || []
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() }) for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
} }
const handleAddCategory = () => { const handleAddCategory = () => {
@@ -719,8 +719,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}> <h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
{t('budget.title')} {t('budget.title')}
</h2> </h2>
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}> <div className="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
<div className="max-md:!w-full" style={{ width: 150 }}> <div style={{ width: 150 }}>
<CustomSelect <CustomSelect
value={currency} value={currency}
onChange={setCurrency} onChange={setCurrency}
@@ -730,7 +730,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
/> />
</div> </div>
{canEdit && ( {canEdit && (
<div className="max-md:!w-full" style={{ display: 'flex', gap: 6, width: 260 }}> <div style={{ display: 'flex', gap: 6, width: 260 }}>
<input <input
value={newCategoryName} value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)} onChange={e => setNewCategoryName(e.target.value)}
@@ -763,7 +763,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'} onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '1'}
> >
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span> <Download size={14} strokeWidth={2.5} /> CSV
</button> </button>
</div> </div>
</div> </div>
@@ -900,30 +900,29 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
}} }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}> <td style={{ ...td, display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> {canEdit && (
{canEdit && ( <div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }} onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }} style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}> <GripVertical size={12} />
<GripVertical size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</div> </div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
<div className="sm:hidden" style={{ marginTop: 4 }}>
<BudgetMemberChips
members={item.members || []}
tripMembers={tripMembers}
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
compact={false}
readOnly={!canEdit}
/>
</div>
)}
</div> </div>
</td> </td>
<td style={{ ...td, textAlign: 'center' }}> <td style={{ ...td, textAlign: 'center' }}>
+1 -1
View File
@@ -768,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
)} )}
{/* Composer */} {/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3"> <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={{
+52 -88
View File
@@ -1,7 +1,7 @@
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight, Plane, Train, Car, Ship } from 'lucide-react' import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client' import { filesApi } from '../../api/client'
@@ -10,7 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl' import { getAuthUrl } from '../../api/authUrl'
import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload' import { downloadFile, openFile } from '../../utils/fileDownload'
function isImage(mimeType) { function isImage(mimeType) {
if (!mimeType) return false if (!mimeType) return false
@@ -113,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
</span> </span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button <button
onClick={() => openFileUrl(file.url, file.original_name).catch(() => {})} onClick={() => openFile(file.url).catch(() => {})}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}> title={t('files.openTab')}>
<ExternalLink size={16} /> <ExternalLink size={16} />
@@ -236,15 +236,6 @@ function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?:
) )
} }
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
function transportIcon(type: string) {
if (type === 'train') return Train
if (type === 'car') return Car
if (type === 'cruise') return Ship
return Plane
}
interface FileManagerProps { interface FileManagerProps {
files?: TripFile[] files?: TripFile[]
onUpload: (fd: FormData) => Promise<any> onUpload: (fd: FormData) => Promise<any>
@@ -499,9 +490,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} /> <SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
))} ))}
{linkedReservations.map(r => ( {linkedReservations.map(r => (
TRANSPORT_TYPES.has(r.type) <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
))} ))}
{file.note_id && ( {file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} /> <SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
@@ -660,17 +649,8 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div> </div>
{dayGroups.map(({ day, dayPlaces }) => ( {dayGroups.map(({ day, dayPlaces }) => (
<div key={day.id}> <div key={day.id}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span> {day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
{(() => {
const badge = day.date || (day.title ? t('dayplan.dayN', { n: day.day_number }) : null)
return badge ? (
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
) : null
})()}
</div> </div>
{dayPlaces.map(placeBtn)} {dayPlaces.map(placeBtn)}
</div> </div>
@@ -684,68 +664,52 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div> </div>
) )
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
const reservationBtn = (r: Reservation) => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
const bookingsSection = reservations.length > 0 && ( const bookingsSection = reservations.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{bookingReservations.length > 0 && ( <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
<> {t('files.assignBooking')}
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}> </div>
{t('files.assignBooking')} {reservations.map(r => {
</div> const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
{bookingReservations.map(reservationBtn)} return (
</> <button key={r.id} onClick={async () => {
)} if (isLinked) {
{transportReservations.length > 0 && ( // Unlink: if primary reservation_id, clear it; if via file_links, remove link
<> if (file.reservation_id === r.id) {
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}> await handleAssign(file.id, { reservation_id: null })
{t('files.assignTransport')} } else {
</div> try {
{transportReservations.map(reservationBtn)} const linksRes = await filesApi.getLinks(tripId, file.id)
</> const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
)} if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
// Link: if no primary, set it; otherwise use file_links
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
})}
</div> </div>
) )
@@ -779,7 +743,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button <button
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} onClick={() => openFile(previewFile.url).catch(() => {})}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'} onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}> onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
@@ -807,7 +771,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
title={previewFile.original_name} title={previewFile.original_name}
> >
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}> <p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button> <button onClick={() => openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
</p> </p>
</object> </object>
</div> </div>
+23 -16
View File
@@ -9,8 +9,6 @@ export interface MapMarkerItem {
label: string label: string
mood?: string | null mood?: string | null
time: string time: string
dayColor: string
dayLabel: number
} }
export interface JourneyMapHandle { export interface JourneyMapHandle {
@@ -26,8 +24,6 @@ interface MapEntry {
title?: string | null title?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
@@ -53,8 +49,6 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
label: e.title || 'Entry', label: e.title || 'Entry',
mood: e.mood, mood: e.mood,
time: e.entry_date, time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
}) })
} }
} }
@@ -65,19 +59,30 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const MARKER_W = 28 const MARKER_W = 28
const MARKER_H = 36 const MARKER_H = 36
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string { function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' // Highlighted: inverted colors for contrast (black on light, white on dark)
const fill = dark
? (highlighted ? '#FAFAFA' : '#A1A1AA')
: (highlighted ? '#18181B' : '#52525B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted const shadow = highlighted
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' ? (dark
? 'filter:drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))' : 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(dayLabel) const label = String(index + 1)
const scale = highlighted ? 1.2 : 1 const scale = highlighted ? 1.2 : 1
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center"> 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"> <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="${dayColor}" stroke="${stroke}" stroke-width="1.5"/> <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="${dayColor}"/> <circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text> <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> </svg>
</div>` </div>`
} }
@@ -110,11 +115,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(prev) const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev) const item = itemsRef.current.find(i => i.id === prev)
if (marker && item) { if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({ marker.setIcon(L.divIcon({
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false), html: markerSvg(idx, false, isDark),
})) }))
marker.setZIndexOffset(0) marker.setZIndexOffset(0)
} }
@@ -124,11 +130,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
const marker = markersRef.current.get(id) const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id) const item = itemsRef.current.find(i => i.id === id)
if (marker && item) { if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({ marker.setIcon(L.divIcon({
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, true), html: markerSvg(idx, true, isDark),
})) }))
marker.setZIndexOffset(1000) marker.setZIndexOffset(1000)
} }
@@ -219,7 +226,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
className: '', className: '',
iconSize: [MARKER_W, MARKER_H], iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H], iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(item.dayColor, item.dayLabel, false), html: markerSvg(i, false, !!dark),
}) })
const marker = L.marker(pos, { icon }).addTo(map) const marker = L.marker(pos, { icon }).addTo(map)
@@ -14,8 +14,6 @@ interface MapEntry {
location_name?: string | null location_name?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
+17 -16
View File
@@ -18,8 +18,6 @@ interface MapEntry {
location_name?: string | null location_name?: string | null
mood?: string | null mood?: string | null
entry_date: string entry_date: string
dayColor?: string
dayLabel?: number
} }
interface Props { interface Props {
@@ -41,8 +39,6 @@ interface Item {
label: string label: string
locationName: string locationName: string
time: string time: string
dayColor: string
dayLabel: number
} }
const MARKER_W = 28 const MARKER_W = 28
@@ -59,8 +55,6 @@ function buildItems(entries: MapEntry[]): Item[] {
label: e.title || '', label: e.title || '',
locationName: e.location_name || '', locationName: e.location_name || '',
time: e.entry_date, time: e.entry_date,
dayColor: e.dayColor || '#52525B',
dayLabel: e.dayLabel ?? 1,
}) })
} }
} }
@@ -163,15 +157,21 @@ function ensureJourneyPopupStyle() {
document.head.appendChild(s) document.head.appendChild(s)
} }
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement { function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
const fill = dayColor const fill = dark
const textColor = '#fff' ? (highlighted ? '#FAFAFA' : '#A1A1AA')
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)' : (highlighted ? '#18181B' : '#52525B')
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
const stroke = highlighted
? (dark ? '#fff' : '#18181B')
: (dark ? '#3F3F46' : '#fff')
const shadow = highlighted const shadow = highlighted
? 'drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))' ? (dark
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const scale = highlighted ? 1.2 : 1 const scale = highlighted ? 1.2 : 1
const label = String(dayLabel) const label = String(index + 1)
// Outer wrap holds the element mapbox positions via `transform: translate(...)`. // Outer wrap holds the element mapbox positions via `transform: translate(...)`.
// Anything animated (scale, filter) has to live on an inner child — otherwise // Anything animated (scale, filter) has to live on an inner child — otherwise
@@ -183,7 +183,7 @@ function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): H
inner.className = 'trek-journey-marker-inner' inner.className = 'trek-journey-marker-inner'
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};` inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg"> inner.innerHTML = `<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="1.5"/> <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}"/> <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> <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>` </svg>`
@@ -273,12 +273,13 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
const item = itemsRef.current.find(i => i.id === id) const item = itemsRef.current.find(i => i.id === id)
const marker = markersRef.current.get(id) const marker = markersRef.current.get(id)
if (!item || !marker) return if (!item || !marker) return
const idx = itemsRef.current.indexOf(item)
const el = marker.getElement() const el = marker.getElement()
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
if (!currentInner) return if (!currentInner) return
// Only swap the inner element's styles/HTML. Touching `el.style.cssText` // Only swap the inner element's styles/HTML. Touching `el.style.cssText`
// would wipe mapbox's positional transform and make the marker flicker. // would wipe mapbox's positional transform and make the marker flicker.
const next = markerHtml(item.dayColor, item.dayLabel, highlighted) const next = markerHtml(idx, highlighted, !!darkRef.current)
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
currentInner.style.cssText = nextInner.style.cssText currentInner.style.cssText = nextInner.style.cssText
currentInner.innerHTML = nextInner.innerHTML currentInner.innerHTML = nextInner.innerHTML
@@ -381,8 +382,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
} }
// markers // markers
items.forEach((item) => { items.forEach((item, i) => {
const el = markerHtml(item.dayColor, item.dayLabel, false) const el = markerHtml(i, false, !!darkRef.current)
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
.setLngLat([item.lng, item.lat]) .setLngLat([item.lng, item.lat])
.addTo(map) .addTo(map)
@@ -1,5 +1,4 @@
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react' import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_ICONS: Record<string, typeof Smile> = { const MOOD_ICONS: Record<string, typeof Smile> = {
@@ -38,14 +37,13 @@ function stripMarkdown(text: string): string {
interface Props { interface Props {
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null } entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
dayLabel: number index: number
dayColor: string
isActive: boolean isActive: boolean
onClick: () => void onClick: () => void
publicPhotoUrl?: (photoId: number) => string publicPhotoUrl?: (photoId: number) => string
} }
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) { export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
const hasLocation = !!(entry.location_lat && entry.location_lng) const hasLocation = !!(entry.location_lat && entry.location_lng)
const hasPhotos = entry.photos && entry.photos.length > 0 const hasPhotos = entry.photos && entry.photos.length > 0
const firstPhoto = hasPhotos ? entry.photos![0] : null const firstPhoto = hasPhotos ? entry.photos![0] : null
@@ -100,8 +98,8 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
<div className="flex-1 p-3 flex flex-col min-w-0"> <div className="flex-1 p-3 flex flex-col min-w-0">
{/* Day number + date + mood/weather */} {/* Day number + date + mood/weather */}
<div className="flex items-center gap-1.5 mb-1"> <div className="flex items-center gap-1.5 mb-1">
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}> <span className="w-5 h-5 rounded bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
{dayLabel} {index + 1}
</span> </span>
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span> <span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
{entry.entry_time && ( {entry.entry_time && (
@@ -143,7 +141,7 @@ export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, o
{hasLocation ? ( {hasLocation ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span> <span className="truncate">{entry.location_name || 'On the map'}</span>
</span> </span>
) : ( ) : (
<span className="text-[10px] text-zinc-400 italic">No location</span> <span className="text-[10px] text-zinc-400 italic">No location</span>
@@ -6,7 +6,6 @@ import {
ThumbsUp, ThumbsDown, ChevronDown, ThumbsUp, ThumbsDown, ChevronDown,
} from 'lucide-react' } from 'lucide-react'
import JournalBody from './JournalBody' import JournalBody from './JournalBody'
import { formatLocationName } from '../../utils/formatters'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore' import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
@@ -25,22 +24,20 @@ const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
cold: { icon: Snowflake, label: 'Cold' }, cold: { icon: Snowflake, label: 'Cold' },
} }
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original'): string {
if (builder) return builder(p.photo_id)
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
interface Props { interface Props {
entry: JourneyEntry entry: JourneyEntry
readOnly?: boolean readOnly?: boolean
publicPhotoUrl?: (photoId: number) => string
onClose: () => void onClose: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
onPhotoClick: (photos: JourneyPhoto[], index: number) => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void
} }
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClose, onEdit, onDelete, onPhotoClick }: Props) { export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDelete, onPhotoClick }: Props) {
const photos = entry.photos || [] const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
@@ -87,7 +84,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{photos.length > 0 && ( {photos.length > 0 && (
<div className="relative"> <div className="relative">
<img <img
src={photoUrl(photos[0], 'original', publicPhotoUrl)} src={photoUrl(photos[0])}
alt="" alt=""
className="w-full max-h-[50vh] object-cover cursor-pointer" className="w-full max-h-[50vh] object-cover cursor-pointer"
onClick={() => onPhotoClick(photos, 0)} onClick={() => onPhotoClick(photos, 0)}
@@ -104,7 +101,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
{photos.map((p, i) => ( {photos.map((p, i) => (
<img <img
key={p.id || i} key={p.id || i}
src={photoUrl(p, 'thumbnail', publicPhotoUrl)} src={photoUrl(p, 'thumbnail')}
alt="" alt=""
className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all" className="w-16 h-16 rounded-lg object-cover flex-shrink-0 cursor-pointer hover:ring-2 ring-zinc-900/30 dark:ring-white/30 transition-all"
onClick={() => onPhotoClick(photos, i)} onClick={() => onPhotoClick(photos, i)}
@@ -133,7 +130,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
<div className="mb-3"> <div className="mb-3">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[12px] font-medium text-zinc-700 dark:text-zinc-300">
<MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" /> <MapPin size={12} className="text-zinc-500 dark:text-zinc-400 flex-shrink-0" />
{formatLocationName(entry.location_name)} {entry.location_name}
</span> </span>
</div> </div>
)} )}
@@ -1,10 +1,9 @@
import { useRef, useState, useEffect, useCallback, useMemo } from 'react' import { useRef, useState, useEffect, useCallback } from 'react'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import JourneyMap from './JourneyMap' import JourneyMap from './JourneyMap'
import MobileEntryCard from './MobileEntryCard' import MobileEntryCard from './MobileEntryCard'
import type { JourneyMapHandle } from './JourneyMap' import type { JourneyMapHandle } from './JourneyMap'
import type { JourneyEntry } from '../../store/journeyStore' import type { JourneyEntry } from '../../store/journeyStore'
import { DAY_COLORS } from './dayColors'
interface MapEntry { interface MapEntry {
id: string id: string
@@ -24,7 +23,6 @@ interface Props {
onEntryClick: (entry: any) => void onEntryClick: (entry: any) => void
onAddEntry?: () => void onAddEntry?: () => void
publicPhotoUrl?: (photoId: number) => string publicPhotoUrl?: (photoId: number) => string
carouselBottom?: string
} }
export default function MobileMapTimeline({ export default function MobileMapTimeline({
@@ -36,23 +34,14 @@ export default function MobileMapTimeline({
onEntryClick, onEntryClick,
onAddEntry, onAddEntry,
publicPhotoUrl, publicPhotoUrl,
carouselBottom = 'calc(var(--bottom-nav-h, 84px) + 8px)',
}: Props) { }: Props) {
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const carouselRef = useRef<HTMLDivElement>(null) const carouselRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(0) const [activeIndex, setActiveIndex] = useState(0)
const entryDayMeta = useMemo(() => {
const uniqueDates = [...new Set(entries.map((e: any) => e.entry_date).sort())]
const counters = new Map<string, number>()
return entries.map((e: any) => {
const dayIdx = uniqueDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return { dayLabel, dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length] }
})
}, [entries])
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map()) const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const activeIndexRef = useRef(activeIndex)
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
// Sync map focus when carousel scrolls (with guard for uninitialized map) // Sync map focus when carousel scrolls (with guard for uninitialized map)
const syncMapToCarousel = useCallback((index: number) => { const syncMapToCarousel = useCallback((index: number) => {
const entry = entries[index] const entry = entries[index]
@@ -87,19 +76,29 @@ export default function MobileMapTimeline({
}) })
}, [syncMapToCarousel]) }, [syncMapToCarousel])
// Defer all state updates until scrolling settles — updating activeIndex // Track scroll; debounce to re-center the active card when the user stops.
// mid-swipe resizes cards (240→320px), causing layout reflow every frame.
useEffect(() => { useEffect(() => {
const el = carouselRef.current const el = carouselRef.current
if (!el || entries.length === 0) return if (!el || entries.length === 0) return
let rafId: number | null = null
let settleTimer: number | null = null let settleTimer: number | null = null
const onScroll = () => { const onScroll = () => {
if (rafId != null) return
rafId = requestAnimationFrame(() => {
pickNearestCard()
rafId = null
})
if (settleTimer != null) window.clearTimeout(settleTimer) if (settleTimer != null) window.clearTimeout(settleTimer)
settleTimer = window.setTimeout(pickNearestCard, 150) settleTimer = window.setTimeout(() => {
// Ensure the active card sits at the center once the user settles.
const card = cardRefs.current.get(activeIndexRef.current)
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}, 180)
} }
el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener('scroll', onScroll, { passive: true })
return () => { return () => {
el.removeEventListener('scroll', onScroll) el.removeEventListener('scroll', onScroll)
if (rafId != null) cancelAnimationFrame(rafId)
if (settleTimer != null) window.clearTimeout(settleTimer) if (settleTimer != null) window.clearTimeout(settleTimer)
} }
}, [entries.length, pickNearestCard]) }, [entries.length, pickNearestCard])
@@ -143,10 +142,7 @@ export default function MobileMapTimeline({
if (entries.length === 0) { if (entries.length === 0) {
return ( return (
<div <div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
entries={mapEntries} entries={mapEntries}
@@ -172,10 +168,7 @@ export default function MobileMapTimeline({
} }
return ( return (
<div <div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
className="fixed left-0 right-0 z-10"
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
>
{/* Full-screen map */} {/* Full-screen map */}
<JourneyMap <JourneyMap
ref={mapRef} ref={mapRef}
@@ -193,13 +186,13 @@ export default function MobileMapTimeline({
{/* Bottom carousel */} {/* Bottom carousel */}
<div <div
className="fixed left-0 right-0 z-40" className="fixed left-0 right-0 z-40"
style={{ touchAction: 'pan-x', bottom: carouselBottom }} style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
> >
<div <div
ref={carouselRef} ref={carouselRef}
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1" className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
style={{ style={{
scrollSnapType: 'x mandatory', scrollSnapType: 'x proximity',
WebkitOverflowScrolling: 'touch', WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none', scrollbarWidth: 'none',
msOverflowStyle: 'none', msOverflowStyle: 'none',
@@ -214,8 +207,7 @@ export default function MobileMapTimeline({
> >
<MobileEntryCard <MobileEntryCard
entry={entry} entry={entry}
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1} index={i}
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
isActive={i === activeIndex} isActive={i === activeIndex}
onClick={() => handleCardTap(entry, i)} onClick={() => handleCardTap(entry, i)}
publicPhotoUrl={publicPhotoUrl} publicPhotoUrl={publicPhotoUrl}
@@ -1,32 +0,0 @@
export const DAY_COLORS = [
'#6366f1', // indigo
'#f97316', // orange
'#14b8a6', // teal
'#ec4899', // pink
'#22c55e', // green
'#3b82f6', // blue
'#a855f7', // purple
'#ef4444', // red
'#f59e0b', // amber
'#06b6d4', // cyan
'#84cc16', // lime
'#f43f5e', // rose
'#8b5cf6', // violet
'#10b981', // emerald
'#fb923c', // orange-400
'#60a5fa', // blue-400
'#c084fc', // purple-400
'#34d399', // emerald-400
'#fbbf24', // amber-400
'#e879f9', // fuchsia
'#4ade80', // green-400
'#f87171', // red-400
'#38bdf8', // sky-400
'#a3e635', // lime-400
'#fb7185', // rose-400
'#818cf8', // indigo-400
'#2dd4bf', // teal-400
'#facc15', // yellow
'#c026d3', // fuchsia-600
'#0ea5e9', // sky-500
]
@@ -19,10 +19,8 @@ vi.mock('react-router-dom', async () => {
import { render, screen, fireEvent } from '../../../tests/helpers/render'; import { render, screen, fireEvent } 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';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories'; import { buildUser } from '../../../tests/helpers/factories';
import BottomNav from './BottomNav'; import BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' }); const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
@@ -41,7 +39,7 @@ describe('BottomNav', () => {
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => { it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />); render(<BottomNav />);
expect(screen.getByText('My Trips')).toBeInTheDocument(); expect(screen.getByText('Trips')).toBeInTheDocument();
}); });
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => { it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
@@ -101,39 +99,4 @@ describe('BottomNav', () => {
// Sheet should be closed — username no longer visible (only the nav Profile text remains) // Sheet should be closed — username no longer visible (only the nav Profile text remains)
expect(screen.queryByText('testuser')).not.toBeInTheDocument(); expect(screen.queryByText('testuser')).not.toBeInTheDocument();
}); });
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Mes voyages')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Profil')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
seedStore(useAddonStore, {
addons: [
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true },
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
],
});
render(<BottomNav />);
expect(screen.getByText('Vacances')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
seedStore(useAddonStore, {
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
});
render(<BottomNav />);
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
});
}); });
+13 -11
View File
@@ -7,10 +7,14 @@ import { useTranslation } from '../../i18n'
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react' import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = { const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' }, { to: '/trips', label: 'Trips', icon: Plane },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' }, ]
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
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() { export default function BottomNav() {
@@ -21,13 +25,11 @@ export default function BottomNav() {
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled) const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
const [showProfile, setShowProfile] = useState(false) const [showProfile, setShowProfile] = useState(false)
const items: { to: string; label: string; icon: LucideIcon }[] = [ const items = [...BASE_ITEMS]
{ to: '/trips', label: t('nav.myTrips'), icon: Plane }, for (const addon of globalAddons) {
...globalAddons.flatMap(addon => { const nav = ADDON_NAV[addon.id]
const nav = ADDON_NAV[addon.id] if (nav) items.push(nav)
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : [] }
}),
]
return ( return (
<> <>
+5 -12
View File
@@ -266,22 +266,17 @@ export default function DemoBanner(): React.ReactElement | null {
return ( return (
<div style={{ <div style={{
position: 'fixed', inset: 0, zIndex: 99999, position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)', background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
paddingTop: 'max(16px, env(safe-area-inset-top))', padding: 16, overflow: 'auto',
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
paddingLeft: 16, paddingRight: 16,
overflow: 'auto',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={() => setDismissed(true)}> }} onClick={() => setDismissed(true)}>
<div style={{ <div style={{
background: 'white', borderRadius: 20, padding: '28px 24px 0', background: 'white', borderRadius: 20, padding: '28px 24px 20px',
maxWidth: 480, width: '100%', maxWidth: 480, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: 'min(90vh, calc(100dvh - 96px))', maxHeight: '90vh', overflow: 'auto',
overflow: 'auto',
display: 'flex', flexDirection: 'column',
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}> }} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
{/* Header */} {/* Header */}
@@ -372,10 +367,8 @@ export default function DemoBanner(): React.ReactElement | null {
{/* Footer */} {/* Footer */}
<div style={{ <div style={{
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb', paddingTop: 14, borderTop: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
position: 'sticky', bottom: 0, background: 'white',
marginTop: 'auto',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} /> <Github size={13} />
+1 -15
View File
@@ -61,25 +61,11 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
navigate('/login', { state: { noRedirect: true } }) navigate('/login', { state: { noRedirect: true } })
} }
// Keep track of the pending theme-transition cleanup so we can cancel it
// on unmount. Without this the timer fires after jsdom teardown in unit
// tests (document is gone) and triggers an unhandled ReferenceError that
// trips vitest's exit code.
const themeTransitionTimer = useRef<number | null>(null)
useEffect(() => () => {
if (themeTransitionTimer.current !== null) {
window.clearTimeout(themeTransitionTimer.current)
themeTransitionTimer.current = null
}
}, [])
const toggleDarkMode = () => { const toggleDarkMode = () => {
document.documentElement.classList.add('trek-theme-transitioning') document.documentElement.classList.add('trek-theme-transitioning')
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {}) updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current) window.setTimeout(() => {
themeTransitionTimer.current = window.setTimeout(() => {
document.documentElement.classList.remove('trek-theme-transitioning') document.documentElement.classList.remove('trek-theme-transitioning')
themeTransitionTimer.current = null
}, 360) }, 360)
} }
+20 -26
View File
@@ -1,15 +1,11 @@
/** /**
* OfflineBanner connectivity + sync state indicator. * OfflineBanner persistent top bar indicating connectivity + sync state.
* *
* States: * States:
* offline + N queued amber pill "Offline · N queued" * offline + N queued amber bar "Offline N changes queued"
* offline + 0 queued amber pill "Offline" * offline + 0 queued amber bar "Offline"
* online + N pending blue pill "Syncing N…" * online + N pending blue bar "Syncing N changes…"
* online + 0 pending hidden * online + 0 pending hidden
*
* Rendered as a small floating pill anchored to the bottom-center of the
* viewport so it never competes with top navigation or sticky modal
* headers. On mobile it hovers just above the bottom tab bar.
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react' import { WifiOff, RefreshCw } from 'lucide-react'
@@ -52,9 +48,9 @@ export default function OfflineBanner(): React.ReactElement | null {
const label = offline const label = offline
? pendingCount > 0 ? pendingCount > 0
? `Offline · ${pendingCount} queued` ? `Offline ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
: 'Offline' : 'Offline'
: `Syncing ${pendingCount}` : `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}`
return ( return (
<div <div
@@ -62,29 +58,27 @@ export default function OfflineBanner(): React.ReactElement | null {
aria-live="polite" aria-live="polite"
style={{ style={{
position: 'fixed', position: 'fixed',
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0, top: 0,
// so the pill sits 16px from the bottom. left: 0,
bottom: 'calc(var(--bottom-nav-h) + 16px)', right: 0,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 9999, zIndex: 9999,
background: bg, background: bg,
color: text, color: text,
display: 'inline-flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 6, justifyContent: 'center',
padding: '6px 14px', gap: 8,
borderRadius: 999, paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)', paddingBottom: '6px',
fontSize: 12, paddingLeft: '16px',
fontWeight: 600, paddingRight: '16px',
whiteSpace: 'nowrap', fontSize: 13,
pointerEvents: 'none', fontWeight: 500,
}} }}
> >
{offline {offline
? <WifiOff size={12} /> ? <WifiOff size={14} />
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} /> : <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
} }
{label} {label}
</div> </div>
+9 -41
View File
@@ -7,16 +7,6 @@ import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories' import { buildPlace } from '../../../tests/helpers/factories'
import * as photoService from '../../services/photoService' import * as photoService from '../../services/photoService'
const mapMock = vi.hoisted(() => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}))
vi.mock('react-leaflet', () => ({ vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>, MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
TileLayer: () => <div data-testid="tile-layer" />, TileLayer: () => <div data-testid="tile-layer" />,
@@ -37,7 +27,15 @@ vi.mock('react-leaflet', () => ({
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />, Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
CircleMarker: () => <div data-testid="circle-marker" />, CircleMarker: () => <div data-testid="circle-marker" />,
Circle: () => <div data-testid="circle" />, Circle: () => <div data-testid="circle" />,
useMap: () => mapMock, useMap: () => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: () => 10,
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}),
useMapEvents: () => ({}), useMapEvents: () => ({}),
})) }))
@@ -81,7 +79,6 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
} }
afterEach(() => { afterEach(() => {
vi.clearAllMocks()
resetAllStores() resetAllStores()
}) })
@@ -219,33 +216,4 @@ describe('MapView', () => {
render(<MapView places={places} selectedPlaceId={5} />) render(<MapView places={places} selectedPlaceId={5} />)
expect(screen.getByTestId('marker')).toBeTruthy() expect(screen.getByTestId('marker')).toBeTruthy()
}) })
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
const initialCount = mapMock.fitBounds.mock.calls.length
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
// paddingOpts memo creates new object). fitBounds must NOT fire again.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
// Toggle selectedPlaceId off — mimics closing inspector via X button.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
})
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(<MapView places={places} fitKey={1} />)
const afterFirst = mapMock.fitBounds.mock.calls.length
rerender(<MapView places={places} fitKey={2} />)
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
})
}) })
+14 -7
View File
@@ -186,7 +186,7 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
} }
} }
} catch {} } catch {}
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps }, [fitKey, places, paddingOpts, map, hasDayDetail])
return null return null
} }
@@ -233,7 +233,18 @@ interface RouteLabelProps {
} }
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
if (!midpoint) return null const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
useEffect(() => {
if (!map) return
const check = () => setVisible(map.getZoom() >= 12)
check()
map.on('zoomend', check)
return () => map.off('zoomend', check)
}, [map])
if (!visible || !midpoint) return null
const icon = L.divIcon({ const icon = L.divIcon({
className: 'route-info-pill', className: 'route-info-pill',
@@ -466,11 +477,7 @@ export const MapView = memo(function MapView({
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
if (!cached && !isLoading(cacheKey)) { if (!cached && !isLoading(cacheKey)) {
const photoId = const photoId = place.image_url || place.google_place_id || place.osm_id
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
if (photoId || (place.lat && place.lng)) { if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
} }
@@ -1,164 +0,0 @@
import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { act } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
// Stable fake map so fitBounds call counts survive re-renders.
const glMap = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
loaded: vi.fn().mockReturnValue(true),
fitBounds: vi.fn(),
flyTo: vi.fn(),
jumpTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
addControl: vi.fn(),
removeControl: vi.fn(),
remove: vi.fn(),
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => document.createElement('div')),
}))
vi.mock('mapbox-gl', () => ({
default: {
accessToken: '',
Map: vi.fn(() => glMap),
Marker: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false),
wantsTerrain: vi.fn(() => false),
addCustom3dBuildings: vi.fn(),
addTerrainAndSky: vi.fn(),
}))
vi.mock('./locationMarkerMapbox', () => ({
attachLocationMarker: vi.fn(() => ({ update: vi.fn() })),
}))
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
}))
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: vi.fn(() => ({
position: null,
mode: 'off',
error: null,
cycleMode: vi.fn(),
setMode: vi.fn(),
})),
}))
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
import { MapViewGL } from './MapViewGL'
function buildMapPlace(overrides: Record<string, any> = {}) {
return {
...buildPlace(),
category_name: null,
category_color: null,
category_icon: null,
...overrides,
} as any
}
beforeEach(() => {
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'mapbox-gl',
mapbox_access_token: 'pk.test_token',
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
mapbox_3d_enabled: false,
},
} as any)
})
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
})
describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-001: opening place inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Selecting a place flips hasInspector → paddingOpts memo changes.
// fitBounds must NOT fire again (this was the bug).
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
it('FE-COMP-MAPVIEWGL-002: closing inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts.
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
it('FE-COMP-MAPVIEWGL-003: bumping fitKey triggers a new fitBounds call', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(<MapViewGL places={places} fitKey={1} />)
await act(async () => {})
const after_first = glMap.fitBounds.mock.calls.length
rerender(<MapViewGL places={places} fitKey={2} />)
await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
})
})
+9 -70
View File
@@ -8,10 +8,9 @@ import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '..
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons' import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup' import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox' import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox'
import LocationButton from './LocationButton' import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation' import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types' import type { Place } from '../../types'
function categoryIconSvg(iconName: string | null | undefined, size: number): string { function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin'] const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -45,10 +44,6 @@ interface Props {
rightWidth?: number rightWidth?: number
hasInspector?: boolean hasInspector?: boolean
hasDayDetail?: boolean hasDayDetail?: boolean
reservations?: Reservation[]
visibleConnectionIds?: number[]
showReservationStats?: boolean
onReservationClick?: (reservationId: number) => void
} }
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement { function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -144,28 +139,17 @@ export function MapViewGL({
rightWidth = 0, rightWidth = 0,
hasInspector = false, hasInspector = false,
hasDayDetail = false, hasDayDetail = false,
reservations = [],
visibleConnectionIds = [],
showReservationStats = false,
onReservationClick,
}: Props) { }: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard') const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '') const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false) const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true) const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled) const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs) const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
const [mapReady, setMapReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null) const mapRef = useRef<mapboxgl.Map | null>(null)
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map()) const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null) const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
// Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation() const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu }) const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick onClickRefs.current.marker = onMarkerClick
@@ -244,10 +228,6 @@ export function MapViewGL({
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: { 'line-cap': 'round', 'line-join': 'round' },
}) })
} }
// Signal that sources/layers are attached so overlay effects can
// safely add their own sources. Style rebuilds reset this via the
// cleanup below.
setMapReady(true)
}) })
map.on('click', (e) => { map.on('click', (e) => {
@@ -319,17 +299,12 @@ export function MapViewGL({
canvas.removeEventListener('auxclick', onAuxClick) canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove()) markersRef.current.forEach(m => m.remove())
markersRef.current.clear() markersRef.current.clear()
if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
}
if (locationMarkerRef.current) { if (locationMarkerRef.current) {
locationMarkerRef.current.destroy() locationMarkerRef.current.destroy()
locationMarkerRef.current = null locationMarkerRef.current = null
} }
try { map.remove() } catch { /* noop */ } try { map.remove() } catch { /* noop */ }
mapRef.current = null mapRef.current = null
setMapReady(false)
} }
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only }, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
@@ -366,11 +341,7 @@ export function MapViewGL({
} }
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
if (!cached && !isLoading(cacheKey)) { if (!cached && !isLoading(cacheKey)) {
const photoId = const photoId = place.image_url || place.google_place_id || place.osm_id
(place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
|| place.google_place_id
|| place.osm_id
|| place.image_url
if (photoId || (place.lat && place.lng)) { if (photoId || (place.lat && place.lng)) {
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
} }
@@ -463,41 +434,6 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features }) src.setData({ type: 'FeatureCollection', features })
}, [places]) }, [places])
// Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
// circle arcs for flights/cruises, straight lines for trains/cars,
// clickable endpoint badges, rotating mid-arc stats label for flights.
// The overlay is a small imperative manager that owns its own source,
// layer, and HTML markers; it lives next to the map for the map's
// lifetime and is rebuilt when the style/token/3d effect rebuilds.
//
// `visibleConnectionIds` is driven by the per-reservation toggle in
// DayPlanSidebar — nothing is rendered until the user enables a
// booking's route, matching the Leaflet MapView's behaviour.
const visibleReservations = useMemo(() => {
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
const set = new Set(visibleConnectionIds)
return reservations.filter(r => set.has(r.id))
}, [reservations, visibleConnectionIds])
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
if (!reservationOverlayRef.current) {
reservationOverlayRef.current = new ReservationMapboxOverlay(map, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}
reservationOverlayRef.current.update(visibleReservations, {
showConnections: true,
showStats: showReservationStats,
showEndpointLabels,
onEndpointClick: (id) => onReservationClickRef.current?.(id),
})
}, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])
// Fit bounds on fitKey change — matches the Leaflet BoundsController // Fit bounds on fitKey change — matches the Leaflet BoundsController
const paddingOpts = useMemo(() => { const paddingOpts = useMemo(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
@@ -507,10 +443,13 @@ export function MapViewGL({
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 } return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
}, [leftWidth, rightWidth, hasInspector, hasDayDetail]) }, [leftWidth, rightWidth, hasInspector, hasDayDetail])
const prevFitKey = useRef(-1) // Also fit when the places collection changes so the initial render
// zooms to the trip instead of the default center.
const placeBoundsKey = useMemo(
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
[places]
)
useEffect(() => { useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
const map = mapRef.current const map = mapRef.current
if (!map) return if (!map) return
const target = dayPlaces.length > 0 ? dayPlaces : places const target = dayPlaces.length > 0 ? dayPlaces : places
@@ -530,7 +469,7 @@ export function MapViewGL({
} }
if (map.loaded()) run() if (map.loaded()) run()
else map.once('load', run) else map.once('load', run)
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps }, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place // flyTo selected place
useEffect(() => { useEffect(() => {
@@ -1,388 +0,0 @@
// Mapbox GL counterpart to ReservationOverlay.tsx.
//
// react-leaflet is component-driven, mapbox-gl is imperative — so instead of
// a React component, this exports a small manager class the MapViewGL wires
// up next to its other sources/layers. The geometry logic (great-circle arcs,
// antimeridian split, duration math) mirrors the Leaflet overlay so both
// renderers produce the same visual result on the globe or a flat projection.
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car } from 'lucide-react'
import type { Reservation, ReservationEndpoint } from '../../types'
export const RESERVATION_SOURCE_ID = 'trek-reservations'
export const RESERVATION_LINE_LAYER_ID = 'trek-reservations-lines'
type TransportType = 'flight' | 'train' | 'cruise' | 'car'
const TRANSPORT_TYPES: TransportType[] = ['flight', 'train', 'cruise', 'car']
const TRANSPORT_COLOR = '#3b82f6'
const TYPE_META: Record<TransportType, { icon: typeof Plane; geodesic: boolean }> = {
flight: { icon: Plane, geodesic: true },
train: { icon: Train, geodesic: false },
cruise: { icon: Ship, geodesic: true },
car: { icon: Car, geodesic: false },
}
// ── geometry helpers (ported from ReservationOverlay.tsx) ────────────────
const toRad = (d: number) => d * Math.PI / 180
const toDeg = (r: number) => r * 180 / Math.PI
function greatCircle(a: [number, number], b: [number, number], steps = 256): [number, number][] {
const [lat1, lng1] = [toRad(a[0]), toRad(a[1])]
const [lat2, lng2] = [toRad(b[0]), toRad(b[1])]
const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lng2 - lng1) / 2) ** 2))
if (d === 0) return [a, b]
const pts: [number, number][] = []
for (let i = 0; i <= steps; i++) {
const f = i / steps
const A = Math.sin((1 - f) * d) / Math.sin(d)
const B = Math.sin(f * d) / Math.sin(d)
const x = A * Math.cos(lat1) * Math.cos(lng1) + B * Math.cos(lat2) * Math.cos(lng2)
const y = A * Math.cos(lat1) * Math.sin(lng1) + B * Math.cos(lat2) * Math.sin(lng2)
const z = A * Math.sin(lat1) + B * Math.sin(lat2)
const lat = Math.atan2(z, Math.sqrt(x * x + y * y))
const lng = Math.atan2(y, x)
pts.push([toDeg(lat), toDeg(lng)])
}
return pts
}
function splitAntimeridian(points: [number, number][]): [number, number][][] {
const segments: [number, number][][] = []
let cur: [number, number][] = []
for (let i = 0; i < points.length; i++) {
if (i > 0 && Math.abs(points[i][1] - points[i - 1][1]) > 180) {
if (cur.length > 1) segments.push(cur)
cur = []
}
cur.push(points[i])
}
if (cur.length > 1) segments.push(cur)
return segments
}
function haversineKm(a: [number, number], b: [number, number]): number {
const R = 6371
const dLat = toRad(b[0] - a[0])
const dLng = toRad(b[1] - a[1])
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a[0])) * Math.cos(toRad(b[0])) * Math.sin(dLng / 2) ** 2
return 2 * R * Math.asin(Math.sqrt(h))
}
function parseInTz(isoLocal: string, tz: string): number {
const [datePart, timePart] = isoLocal.split('T')
const [y, mo, d] = datePart.split('-').map(Number)
const [h, mi] = (timePart || '00:00').split(':').map(Number)
const guess = Date.UTC(y, mo - 1, d, h, mi)
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: tz, hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
})
const parts = Object.fromEntries(fmt.formatToParts(new Date(guess)).filter(p => p.type !== 'literal').map(p => [p.type, p.value]))
const asUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour) % 24, Number(parts.minute), Number(parts.second))
return guess - (asUtc - guess)
}
function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fallbackStart: string | null, fallbackEnd: string | null): string | null {
let start = from.local_date && from.local_time ? `${from.local_date}T${from.local_time}` : fallbackStart
let end = to.local_date && to.local_time ? `${to.local_date}T${to.local_time}` : fallbackEnd
if (!start || !end) return null
if (!start.includes('T') && end.includes('T')) start = `${end.split('T')[0]}T${start}`
if (!end.includes('T') && start.includes('T')) end = `${start.split('T')[0]}T${end}`
if (!start.includes('T') || !end.includes('T')) return null
const fromTz = from.timezone || to.timezone
const toTz = to.timezone || fromTz
let startMs: number, endMs: number
if (fromTz && toTz) {
startMs = parseInTz(start, fromTz)
endMs = parseInTz(end, toTz)
} else {
startMs = new Date(start).getTime()
endMs = new Date(end).getTime()
}
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return null
if (endMs <= startMs) endMs += 24 * 60 * 60000
const minutes = Math.round((endMs - startMs) / 60000)
if (minutes <= 0 || minutes > 48 * 60) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
const cleanName = (name: string) => name.replace(/\s*\([^)]*\)/g, '').trim()
// ── item building ─────────────────────────────────────────────────────────
interface TransportItem {
res: Reservation
from: ReservationEndpoint
to: ReservationEndpoint
type: TransportType
arcs: [number, number][][]
primaryArc: [number, number][]
mainLabel: string | null
subLabel: string | null
}
function buildItems(reservations: Reservation[]): TransportItem[] {
const out: TransportItem[] = []
for (const r of reservations) {
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
const eps = r.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
if (!from || !to) continue
const type = r.type as TransportType
const isGeo = TYPE_META[type].geodesic
const arcs = isGeo
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
const primaryArc = arcs[primaryIdx] ?? []
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
const mainLabel = from.code && to.code ? `${from.code}${to.code}` : null
const subParts = [duration, distance].filter(Boolean) as string[]
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
}
return out
}
// ── DOM helpers for HTML markers ──────────────────────────────────────────
function endpointMarkerHtml(type: TransportType, label: string | null): string {
const { icon: IconCmp } = TYPE_META[type]
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''
return `<div style="
display:inline-flex;align-items:center;justify-content:center;gap:4px;
padding:0 8px;border-radius:999px;
background:${TRANSPORT_COLOR};box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1.5px solid #fff;color:#fff;
font-family:-apple-system,system-ui,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
box-sizing:border-box;height:22px;white-space:nowrap;cursor:pointer;
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml}</div>`
}
function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { html: string; width: number; height: number } {
const estWidth = Math.max(
mainLabel ? mainLabel.length * 6.5 : 0,
subLabel ? subLabel.length * 5.5 : 0,
) + 22
const hasBoth = !!mainLabel && !!subLabel
const height = hasBoth ? 36 : 22
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
const html = `<div class="trek-stats-inner" style="
display:flex;flex-direction:column;align-items:center;justify-content:center;
width:100%;height:100%;
padding:0 11px;border-radius:999px;
background:rgba(17,24,39,0.92);color:#fff;
box-shadow:0 2px 6px rgba(0,0,0,0.25);
border:1px solid ${TRANSPORT_COLOR}aa;
font-family:-apple-system,system-ui,'SF Pro Text',sans-serif;
white-space:nowrap;box-sizing:border-box;pointer-events:none;
transform-origin:center;will-change:transform;
">${main}${sub}</div>`
return { html, width: estWidth, height }
}
// ── overlay manager ──────────────────────────────────────────────────────
export interface ReservationOverlayOptions {
showConnections: boolean
showStats: boolean
showEndpointLabels: boolean
onEndpointClick?: (reservationId: number) => void
}
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private opts: ReservationOverlayOptions
private endpointMarkers: mapboxgl.Marker[] = []
private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
private rerender: () => void
private destroyed = false
constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
this.map = map
this.opts = opts
this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer()
map.on('zoomend', this.rerender)
map.on('moveend', this.rerender)
map.on('render', this.updateStatsRotation)
}
update(reservations: Reservation[], opts: ReservationOverlayOptions) {
this.opts = opts
this.items = buildItems(reservations)
this.render()
}
destroy() {
this.destroyed = true
this.map.off('zoomend', this.rerender)
this.map.off('moveend', this.rerender)
this.map.off('render', this.updateStatsRotation)
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
try {
if (this.map.getLayer(RESERVATION_LINE_LAYER_ID)) this.map.removeLayer(RESERVATION_LINE_LAYER_ID)
if (this.map.getSource(RESERVATION_SOURCE_ID)) this.map.removeSource(RESERVATION_SOURCE_ID)
} catch { /* map already gone */ }
}
private setupLayer() {
const map = this.map
if (map.getSource(RESERVATION_SOURCE_ID)) return
map.addSource(RESERVATION_SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({
id: RESERVATION_LINE_LAYER_ID,
type: 'line',
source: RESERVATION_SOURCE_ID,
paint: {
'line-color': TRANSPORT_COLOR,
'line-width': 2.5,
// Confirmed = solid + 0.75; pending = dashed + 0.55.
'line-opacity': ['case', ['==', ['get', 'status'], 'confirmed'], 0.75, 0.55] as any,
'line-dasharray': ['case', ['==', ['get', 'status'], 'confirmed'], ['literal', [1, 0]], ['literal', [3, 3]]] as any,
},
layout: { 'line-cap': 'round', 'line-join': 'round' },
})
}
private render() {
const map = this.map
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
const show = this.opts.showConnections
// Visible filter: require the on-screen pixel distance between
// endpoints to exceed a type-specific minimum, same as the Leaflet
// overlay, so tiny no-op transport lines don't clutter the map.
const visibleItems = show ? this.items.filter(item => {
try {
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
return dist >= minPx
} catch { return true }
}) : []
// Label visibility threshold is higher than line visibility, to keep
// endpoint text from overlapping on very short lines.
const labelVisibleIds = new Set<number>()
if (show) {
for (const item of visibleItems) {
try {
const fromPx = map.project([item.from.lng, item.from.lat])
const toPx = map.project([item.to.lng, item.to.lat])
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
const dist = Math.sqrt(dx * dx + dy * dy)
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : 400
if (dist >= minPx) labelVisibleIds.add(item.res.id)
} catch { /* ignore */ }
}
}
// ── line features ───────────────────────────────────────────────
const features = visibleItems.flatMap(item => item.arcs.map(seg => ({
type: 'Feature' as const,
properties: {
resId: item.res.id,
type: item.type,
status: item.res.status ?? 'pending',
},
geometry: {
type: 'LineString' as const,
coordinates: seg.map(([lat, lng]) => [lng, lat]),
},
})))
const src = map.getSource(RESERVATION_SOURCE_ID) as mapboxgl.GeoJSONSource | undefined
src?.setData({ type: 'FeatureCollection', features })
// ── endpoint markers ────────────────────────────────────────────
this.endpointMarkers.forEach(m => m.remove())
this.endpointMarkers = []
if (show) {
for (const item of visibleItems) {
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
for (const ep of [item.from, item.to]) {
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
const el = document.createElement('div')
el.innerHTML = endpointMarkerHtml(item.type, label)
const inner = el.firstElementChild as HTMLElement | null
const node = inner ?? el
node.title = ep.name || ''
if (this.opts.onEndpointClick) {
node.addEventListener('click', (ev) => {
ev.stopPropagation()
this.opts.onEndpointClick?.(item.res.id)
})
}
const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat])
.addTo(map)
this.endpointMarkers.push(marker)
}
}
}
// ── stats label (flights only) ──────────────────────────────────
this.statsMarkers.forEach(s => s.marker.remove())
this.statsMarkers = []
if (show && this.opts.showStats) {
for (const item of visibleItems) {
if (item.type !== 'flight') continue
if (!labelVisibleIds.has(item.res.id)) continue
if (!item.mainLabel && !item.subLabel) continue
const arc = item.primaryArc
if (arc.length < 2) continue
const mid = arc[Math.floor(arc.length / 2)]!
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
const el = document.createElement('div')
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
el.innerHTML = html
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([mid[1], mid[0]])
.addTo(map)
this.statsMarkers.push({ marker, arc })
}
}
// Prime rotation once so labels don't flash horizontal on first paint.
this.updateStatsRotation()
}
// Match the Leaflet overlay's "rotate the label along the arc" look.
// We pick a short segment straddling the arc midpoint, measure the
// screen angle between those two projected points, and clamp it to
// [-90°, 90°] so text never renders upside-down.
private updateStatsRotation = () => {
if (this.destroyed) return
for (const entry of this.statsMarkers) {
const { marker, arc } = entry
if (arc.length < 2) continue
const midIdx = Math.floor(arc.length / 2)
const a = arc[Math.max(0, midIdx - 2)]!
const b = arc[Math.min(arc.length - 1, midIdx + 2)]!
try {
const pa = this.map.project([a[1], a[0]])
const pb = this.map.project([b[1], b[0]])
let angle = Math.atan2(pb.y - pa.y, pb.x - pa.x) * 180 / Math.PI
if (angle > 90) angle -= 180
if (angle < -90) angle += 180
const el = marker.getElement()
const inner = el.querySelector('.trek-stats-inner') as HTMLElement | null
if (inner) inner.style.transform = `rotate(${angle}deg)`
} catch { /* map not ready / projection failure */ }
}
}
}
@@ -78,7 +78,6 @@ const transportReservation = {
id: 400, id: 400,
title: 'Flight to Rome', title: 'Flight to Rome',
type: 'flight', type: 'flight',
day_id: 10,
reservation_time: '2025-06-01T14:30:00', reservation_time: '2025-06-01T14:30:00',
confirmation_number: 'ABC123', confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }), metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
+12 -56
View File
@@ -4,7 +4,6 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react' import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client' import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
function renderLucideIcon(icon:LucideIcon, props = {}) { function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return '' if (!_renderToStaticMarkup) return ''
@@ -97,12 +96,12 @@ async function fetchPlacePhotos(assignments) {
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean) const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()] const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_id)) const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled( await Promise.allSettled(
toFetch.map(async (place) => { toFetch.map(async (place) => {
try { try {
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name) const data = await mapsApi.placePhoto(place.google_place_id)
if (data.photoUrl) photoMap[place.id] = data.photoUrl if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {} } catch {}
}) })
@@ -141,58 +140,23 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const totalCost = Object.values(assignments || {}) const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) .flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
// Span helpers for multi-day transport (mirrors DayPlanSidebar logic)
const pdfGetDayOrder = (d: Day) => d.day_number
const pdfGetSpanPhase = (r: any, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (!startId || startId === endId) return 'single'
if (dayId === startId) return 'start'
if (dayId === endId) return 'end'
return 'middle'
}
const pdfGetDisplayTime = (r: any, dayId: number): string | null => {
const phase = pdfGetSpanPhase(r, dayId)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
}
const pdfGetSpanLabel = (r: any, phase: string): string | null => {
if (phase === 'single') return null
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
if (r.type === 'hotel') return false
const startId = r.day_id
const endId = r.end_day_id ?? startId
if (startId == null) return false
if (endId !== startId) {
const startDay = sorted.find(d => d.id === startId)
const endDay = sorted.find(d => d.id === endId)
const thisDay = sorted.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return pdfGetDayOrder(thisDay) >= pdfGetDayOrder(startDay) && pdfGetDayOrder(thisDay) <= pdfGetDayOrder(endDay)
}
return startId === dayId
})
// Build day HTML // Build day HTML
const daysHtml = sorted.map((day, di) => { const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || [] const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id) const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc) const cost = dayCost(assignments, day.id, loc)
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only) // Reservations for this day (hotel rendered via accommodations block)
const dayReservations = pdfGetTransportForDay(day.id) const dayReservations = (reservations || []).filter(r => {
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle')) if (!r.reservation_time || r.type === 'hotel') return false
return day.date && r.reservation_time.split('T')[0] === day.date
})
const merged = [] const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a })) assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n })) notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayReservations.forEach(r => { dayReservations.forEach(r => {
const pos = r.day_positions?.[day.id] ?? r.day_positions?.[String(day.id)] ?? r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5) const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'reservation', k: pos, data: r }) merged.push({ type: 'reservation', k: pos, data: r })
}) })
merged.sort((a, b) => a.k - b.k) merged.sort((a, b) => a.k - b.k)
@@ -213,17 +177,13 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ') else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ') else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
const locationLine = r.location || meta.location || '' const locationLine = r.location || meta.location || ''
const phase = pdfGetSpanPhase(r, day.id) const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
const spanLabel = pdfGetSpanLabel(r, phase)
const displayTime = pdfGetDisplayTime(r, day.id)
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
return ` return `
<div class="note-card" style="border-left: 3px solid ${color};"> <div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: ${color};"></div> <div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span> <span class="note-icon">${icon}</span>
<div class="note-body"> <div class="note-body">
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div> <div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''} ${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''} ${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''} ${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
@@ -286,12 +246,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
}).join('') }).join('')
const accommodationsForDay = (accommodations.accommodations || []).filter(a => const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
).sort((a, b) => { ).sort((a, b) => a.start_day_id - b.start_day_id)
const startA = days.find(d => d.id === a.start_day_id)
const startB = days.find(d => d.id === b.start_day_id)
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
})
const accommodationDetails = accommodationsForDay.map(item => { const accommodationDetails = accommodationsForDay.map(item => {
const isCheckIn = day.id === item.start_day_id const isCheckIn = day.id === item.start_day_id
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Package } from 'lucide-react' import { Package } from 'lucide-react'
import { adminApi, packingApi } from '../../api/client' import { adminApi, packingApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
@@ -44,9 +43,9 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
setApplying(true) setApplying(true)
try { try {
const data = await packingApi.applyTemplate(tripId, templateId) const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count })) toast.success(t('packing.templateApplied', { count: data.count }))
setOpen(false) setOpen(false)
window.location.reload()
} catch { } catch {
toast.error(t('packing.templateError')) toast.error(t('packing.templateError'))
} finally { } finally {
@@ -208,14 +208,9 @@ interface ArtikelZeileProps {
canEdit?: boolean canEdit?: boolean
} }
// A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI.
const PACKING_PLACEHOLDER_NAME = '...'
function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) { function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name) const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false) const [showCatPicker, setShowCatPicker] = useState(false)
const [showBagPicker, setShowBagPicker] = useState(false) const [showBagPicker, setShowBagPicker] = useState(false)
@@ -228,7 +223,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked) const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => { const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : item.name); return } if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) } try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) } catch { toast.error(t('packing.toast.saveError')) }
} }
@@ -280,10 +275,9 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
{editing && canEdit ? ( {editing && canEdit ? (
<input <input
type="text" value={editName} autoFocus type="text" value={editName} autoFocus
placeholder={isPlaceholder ? '...' : undefined}
onChange={e => setEditName(e.target.value)} onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName} onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : item.name) } }} onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }} style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/> />
) : ( ) : (
@@ -292,7 +286,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
style={{ style={{
flex: 1, fontSize: 13.5, flex: 1, fontSize: 13.5,
cursor: !canEdit || item.checked ? 'default' : 'text', cursor: !canEdit || item.checked ? 'default' : 'text',
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'), color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)', transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
textDecoration: item.checked ? 'line-through' : 'none', textDecoration: item.checked ? 'line-through' : 'none',
}} }}
@@ -965,9 +959,10 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
setApplyingTemplate(true) setApplyingTemplate(true)
try { try {
const data = await packingApi.applyTemplate(tripId, templateId) const data = await packingApi.applyTemplate(tripId, templateId)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
toast.success(t('packing.templateApplied', { count: data.count })) toast.success(t('packing.templateApplied', { count: data.count }))
setShowTemplateDropdown(false) setShowTemplateDropdown(false)
// Reload packing items
window.location.reload()
} catch { } catch {
toast.error(t('packing.templateError')) toast.error(t('packing.templateError'))
} finally { } finally {
@@ -1025,10 +1020,10 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return } if (parsed.length === 0) { toast.error(t('packing.importEmpty')); return }
try { try {
const result = await packingApi.bulkImport(tripId, parsed) const result = await packingApi.bulkImport(tripId, parsed)
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(result.items || [])] }))
toast.success(t('packing.importSuccess', { count: result.count })) toast.success(t('packing.importSuccess', { count: result.count }))
setImportText('') setImportText('')
setShowImportModal(false) setShowImportModal(false)
window.location.reload()
} catch { toast.error(t('packing.importError')) } } catch { toast.error(t('packing.importError')) }
} }
@@ -892,277 +892,6 @@ describe('DayDetailPanel', () => {
expect(screen.getByText(/June|15/i)).toBeInTheDocument(); expect(screen.getByText(/June|15/i)).toBeInTheDocument();
}); });
// ── Accommodation date-range picker — non-monotonic day IDs (issue #889) ─────
// Builds the reporter's exact ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
// This happens after repeated trip-length changes via generateDays (no import/migration needed).
function buildNonMonotonicDays() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30' }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01' }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02' }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03' }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04' }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05' }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06' }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07' }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08' }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09' }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10' }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11' }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12' }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13' }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14' }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15' }),
];
}
// Returns the two CustomSelect trigger buttons for start/end day pickers.
// When no dropdown is open, these are the only globally-visible buttons whose textContent
// matches /Day \d+/ (the main panel title is a div, not a button).
// [0] = start trigger, [1] = end trigger (DOM source order).
function getDayPickerTriggers() {
return screen.getAllByRole('button').filter(b => /Day \d+/.test(b.textContent ?? ''));
}
it('FE-PLANNER-DAYDETAIL-056: non-monotonic IDs — end picker does not clobber start-day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 50, name: 'Range Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 99, place_id: 50, place_name: 'Range Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Range Hotel/i }));
// Both triggers show "Day 1"; the second one is the end picker.
await userEvent.click(getDayPickerTriggers()[1]);
// Select "Day 16" (id=7) from the open dropdown — textContent starts with "Day 16".
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
// start must remain id 17 (day 1) — old code would clobber it to id 7 via Math.min
expect(capturedBody?.start_day_id).toBe(17);
expect(capturedBody?.end_day_id).toBe(7);
});
});
it('FE-PLANNER-DAYDETAIL-057: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 51, name: 'Span Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 100, place_id: 51, place_name: 'Span Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Span Hotel/i }));
// Set end to day 16 (id=7, low ID but last day by position).
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to day 9 (id=25, high ID, but earlier by position than day 16).
// Old code: Math.max(25, 7) = 25 → end collapses to day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays at 7 (day 16).
await userEvent.click(getDayPickerTriggers()[0]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(capturedBody?.start_day_id).toBe(25); // day 9
expect(capturedBody?.end_day_id).toBe(7); // day 16 — must NOT have collapsed
});
});
it('FE-PLANNER-DAYDETAIL-058: non-monotonic IDs — All days button sets correct first/last IDs', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 52, name: 'Full Trip Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 101, place_id: 52, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Full Trip Hotel/i }));
// "All" is the day.allDays translation (en: "All") — the Apply-to-entire-trip button.
// When categories=[] the category-filter "All" button is not rendered, so this is unique.
await userEvent.click(screen.getByRole('button', { name: /^All$/i }));
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
// days[0].id=17 (first by position), days[15].id=7 (last by position)
expect(capturedBody?.start_day_id).toBe(17);
expect(capturedBody?.end_day_id).toBe(7);
});
});
it('FE-PLANNER-DAYDETAIL-059: sequential IDs — end picker clamping still works (regression guard)', async () => {
const seqDays = [
buildDay({ id: 101, trip_id: 1, date: '2026-06-01' }),
buildDay({ id: 102, trip_id: 1, date: '2026-06-02' }),
buildDay({ id: 103, trip_id: 1, date: '2026-06-03' }),
];
const place = buildPlace({ id: 53, name: 'Seq Hotel' });
let capturedBody: any;
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
capturedBody = await request.json();
return HttpResponse.json({
accommodation: {
id: 102, place_id: 53, place_name: 'Seq Hotel', place_address: null,
start_day_id: capturedBody.start_day_id, end_day_id: capturedBody.end_day_id,
check_in: null, check_out: null, confirmation: null,
},
});
}),
);
render(<DayDetailPanel {...defaultProps} day={seqDays[0]} days={seqDays} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Seq Hotel/i }));
// Pick end = day 3 (id=103, position 2 > position 0 of start id=101).
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 3'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
await waitFor(() => {
expect(capturedBody?.start_day_id).toBe(101);
expect(capturedBody?.end_day_id).toBe(103);
});
});
// ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ────────
it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => {
const days = buildNonMonotonicDays();
let getCallCount = 0;
server.use(
http.get('/api/trips/1/accommodations', () => {
getCallCount++;
const acc = getCallCount === 1
// Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible
? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null }
// Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it
: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null };
return HttpResponse.json({ accommodations: [acc] });
}),
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Span Hotel');
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
const allButtons = screen.getAllByRole('button');
await userEvent.click(allButtons[2]);
// Extend end picker to Day 16 (id=7)
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible.
await waitFor(() => {
expect(screen.getByText('Span Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 55, name: 'Created Hotel' });
// Current day: days[5] = id 22, position 5 (within any full-span range)
const currentDay = days[5];
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={currentDay} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i }));
// Extend end to Day 16 (id=7) — start stays at current day id=22
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible.
await waitFor(() => {
expect(screen.getByText('Created Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => {
const days = buildNonMonotonicDays();
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }],
})
),
);
// Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Full Trip Hotel');
// Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[9]} days={days} />);
await screen.findByText('Full Trip Hotel');
});
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => { it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
seedStore(useSettingsStore, { seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false }, settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
@@ -12,7 +12,6 @@ import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { getLocaleForLanguage, useTranslation } from '../../i18n' import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
const WEATHER_ICON_MAP = { const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -67,11 +66,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
const fmtTime = (v) => { const fmtTime = (v) => formatTime12(v, is12h)
if (!v) return v
if (v.includes('T')) return new Date(v).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
return formatTime12(v, is12h)
}
const unit = isFahrenheit ? '°F' : '°C' const unit = isFahrenheit ? '°F' : '°C'
const collapsed = collapsedProp const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.() const toggleCollapse = () => onToggleCollapse?.()
@@ -100,7 +95,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
.then(data => { .then(data => {
setAccommodations(data.accommodations || []) setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a => const allForDay = (data.accommodations || []).filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
) )
setDayAccommodations(allForDay) setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null) setAccommodation(allForDay[0] || null)
@@ -131,7 +126,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
setAccommodations(updated) setAccommodations(updated)
setAccommodation(newAcc) setAccommodation(newAcc)
setDayAccommodations(updated.filter(a => setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
)) ))
setShowHotelPicker(false) setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null }) setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
@@ -155,7 +150,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updated = accommodations.filter(a => a.id !== accommodation.id) const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated) setAccommodations(updated)
setDayAccommodations(updated.filter(a => setDayAccommodations(updated.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
)) ))
setAccommodation(null) setAccommodation(null)
onAccommodationChange?.() onAccommodationChange?.()
@@ -173,7 +168,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 className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', 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 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%)',
@@ -464,13 +459,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect <CustomSelect
value={hotelDayRange.start} value={hotelDayRange.start}
onChange={v => setHotelDayRange(prev => ({ start: v, end: days.findIndex(d => d.id === v) > days.findIndex(d => d.id === prev.end) ? v : prev.end }))} onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
options={days.map((d, i) => ({ options={days.map((d, i) => ({
value: d.id, value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }), label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
badge: d.date
? new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: (d.title ? t('planner.dayN', { n: i + 1 }) : undefined),
}))} }))}
size="sm" size="sm"
/> />
@@ -479,13 +471,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect <CustomSelect
value={hotelDayRange.end} value={hotelDayRange.end}
onChange={v => setHotelDayRange(prev => ({ start: days.findIndex(d => d.id === v) < days.findIndex(d => d.id === prev.start) ? v : prev.start, end: v }))} onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
options={days.map((d, i) => ({ options={days.map((d, i) => ({
value: d.id, value: d.id,
label: d.title || t('planner.dayN', { n: i + 1 }), label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? `${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
badge: d.date
? new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: (d.title ? t('planner.dayN', { n: i + 1 }) : undefined),
}))} }))}
size="sm" size="sm"
/> />
@@ -599,9 +588,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const all = d.accommodations || [] const all = d.accommodations || []
setAccommodations(all) setAccommodations(all)
setDayAccommodations(all.filter(a => setDayAccommodations(all.filter(a =>
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
)) ))
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false) const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
setAccommodation(acc || null) setAccommodation(acc || null)
}) })
onAccommodationChange?.() onAccommodationChange?.()
+132 -52
View File
@@ -2,7 +2,7 @@
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' } interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
declare global { interface Window { __dragData: DragDataPayload | null } } declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, 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, ChevronsDownUp, ChevronsUpDown, 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, Route as RouteIcon } from 'lucide-react' import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, 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, Route as RouteIcon } from 'lucide-react'
@@ -14,7 +14,6 @@ import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import WeatherWidget from '../Weather/WeatherWidget' import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
@@ -22,12 +21,6 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore' import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
type MergedItem,
} from '../../utils/dayMerge'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip' import Tooltip from '../shared/Tooltip'
@@ -196,8 +189,6 @@ interface DayPlanSidebarProps {
onEditTransport?: (reservation: Reservation) => void onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void onEditReservation?: (reservation: Reservation) => void
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
} }
const DayPlanSidebar = React.memo(function DayPlanSidebar({ const DayPlanSidebar = React.memo(function DayPlanSidebar({
@@ -226,8 +217,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onEditTransport, onEditTransport,
onEditReservation, onEditReservation,
onAddBookingToAssignment, onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
}: DayPlanSidebarProps) { }: DayPlanSidebarProps) {
const toast = useToast() const toast = useToast()
const { t, language, locale } = useTranslation() const { t, language, locale } = useTranslation()
@@ -280,12 +269,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} | null>(null) } | null>(null)
const inputRef = useRef(null) const inputRef = useRef(null)
const dragDataRef = useRef(null) const dragDataRef = useRef(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren) const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
// Remember which assignment we last auto-scrolled into view so we don't // Remember which assignment we last auto-scrolled into view so we don't
// keep yanking the user back whenever they scroll away while the same // keep yanking the user back whenever they scroll away while the same
@@ -353,10 +336,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return () => document.removeEventListener('dragend', cleanup) return () => document.removeEventListener('dragend', cleanup)
}, []) }, [])
// Initialize missing transport positions outside of render to avoid setState-during-render
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { days.forEach(day => initTransportPositions(day.id)) }, [days, reservations])
const toggleDay = (dayId, e) => { const toggleDay = (dayId, e) => {
e.stopPropagation() e.stopPropagation()
setExpandedDays(prev => { setExpandedDays(prev => {
@@ -367,6 +346,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
}) })
} }
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
// Get span phase: how a reservation relates to a specific day (by id)
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
const startDayId = r.day_id
const endDayId = r.end_day_id ?? startDayId
if (!startDayId || startDayId === endDayId) return 'single'
if (dayId === startDayId) return 'start'
if (dayId === endDayId) return 'end'
return 'middle'
}
// Get the appropriate display time for a reservation on a specific day
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
const phase = getSpanPhase(r, dayId)
if (phase === 'end') return r.reservation_end_time || null
if (phase === 'middle') return null
return r.reservation_time || null
}
// Get phase label for multi-day badge // Get phase label for multi-day badge
const getSpanLabel = (r: Reservation, phase: string): string | null => { const getSpanLabel = (r: Reservation, phase: string): string | null => {
if (phase === 'single') return null if (phase === 'single') return null
@@ -391,8 +390,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return { day_id: startId, end_day_id: targetDayId } return { day_id: startId, end_day_id: targetDayId }
} }
const getTransportForDay = (dayId: number) => const getTransportForDay = (dayId: number) => {
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days }) const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
return reservations.filter(r => {
if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id
const endDayId = r.end_day_id ?? startDayId
if (startDayId == null) return false
if (endDayId !== startDayId) {
const startDay = days.find(d => d.id === startDayId)
const endDay = days.find(d => d.id === endDayId)
const thisDay = days.find(d => d.id === dayId)
if (!startDay || !endDay || !thisDay) return false
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
}
return startDayId === dayId
})
}
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline // Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
const getActiveRentalsForDay = (dayId: number) => { const getActiveRentalsForDay = (dayId: number) => {
@@ -412,6 +430,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const getDayAssignments = (dayId) => const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
// Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null
const parseTimeToMinutes = (time?: string | null): number | null => {
if (!time) return null
// ISO-Format "2025-03-30T09:00:00"
if (time.includes('T')) {
const [h, m] = time.split('T')[1].split(':').map(Number)
return h * 60 + m
}
// Einfaches "HH:MM" Format
const parts = time.split(':').map(Number)
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
return null
}
// Compute initial day_plan_position for a transport based on time // Compute initial day_plan_position for a transport based on time
const computeTransportPosition = (r, da) => { const computeTransportPosition = (r, da) => {
const minutes = parseTimeToMinutes(r.reservation_time) ?? 0 const minutes = parseTimeToMinutes(r.reservation_time) ?? 0
@@ -453,14 +485,69 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
reservationsApi.updatePositions(tripId, positions).catch(() => {}) reservationsApi.updatePositions(tripId, positions).catch(() => {})
} }
const getMergedItems = (dayId: number): MergedItem[] => const getMergedItems = (dayId) => {
_getMergedItems({ const da = getDayAssignments(dayId)
dayAssignments: getDayAssignments(dayId), const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order), const transport = getTransportForDay(dayId)
dayTransports: getTransportForDay(dayId),
dayId, // Initialize positions for transports that don't have one yet
getDisplayTime: getDisplayTimeForDay, if (transport.some(r => r.day_plan_position == null)) {
}) initTransportPositions(dayId)
}
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
const baseItems = [
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
// Transports are inserted among places based on time
const timedTransports = transport.map(r => ({
type: 'transport' as const,
data: r,
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
})).sort((a, b) => a.minutes - b.minutes)
if (timedTransports.length === 0) return baseItems
if (baseItems.length === 0) {
return timedTransports.map((item, i) => ({ ...item, sortKey: i }))
}
// Insert transports among places based on per-day position or time
const result = [...baseItems]
for (let ti = 0; ti < timedTransports.length; ti++) {
const timed = timedTransports[ti]
const minutes = timed.minutes
// Use per-day position if explicitly set by user reorder
const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
if (perDayPos != null) {
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
continue
}
// Find insertion position: after the last place with time <= this transport's time
let insertAfterKey = -Infinity
for (const item of result) {
if (item.type === 'place') {
const pm = parseTimeToMinutes(item.data?.place?.place_time)
if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey
} else if (item.type === 'transport') {
const tm = parseTimeToMinutes(item.data?.reservation_time)
if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey
}
}
const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0
const sortKey = insertAfterKey === -Infinity
? lastKey + 0.5 + ti * 0.01
: insertAfterKey + 0.01 + ti * 0.001
result.push({ type: timed.type, sortKey, data: timed.data })
}
return result.sort((a, b) => a.sortKey - b.sortKey)
}
// Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover) // Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1030,7 +1117,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div> </div>
{/* Tagesliste */} {/* Tagesliste */}
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> <div className="scroll-container trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{days.map((day, index) => { {days.map((day, index) => {
const isSelected = selectedDayId === day.id const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id) const isExpanded = expandedDays.has(day.id)
@@ -1048,7 +1135,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */} {/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div <div
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }} onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)} onDrop={e => handleDropOnDay(e, day.id)}
style={{ style={{
@@ -1128,7 +1215,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</Tooltip> </Tooltip>
)} )}
{(() => { {(() => {
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
// Sort: check-out first, then ongoing stays, then check-in last // Sort: check-out first, then ongoing stays, then check-in last
.sort((a, b) => { .sort((a, b) => {
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
@@ -1149,9 +1236,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)' const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)'
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)' const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)'
return ( return (
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}> <span key={acc.id} onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} /> <Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span> <span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
</span> </span>
) )
}) })
@@ -1262,7 +1349,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
> >
{merged.length === 0 && !dayNoteUi ? ( {merged.length === 0 && !dayNoteUi ? (
<div <div
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)} onDrop={e => handleDropOnDay(e, day.id)}
style={{ padding: '16px', textAlign: 'center', borderRadius: 8, style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent', background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
@@ -1322,6 +1409,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return ( return (
<React.Fragment key={`place-${assignment.id}`}> <React.Fragment key={`place-${assignment.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div <div
draggable={canEditDays} draggable={canEditDays}
onDragStart={e => { onDragStart={e => {
@@ -1411,7 +1499,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
borderLeft: lockedIds.has(assignment.id) borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626' ? '3px solid #dc2626'
: '3px solid transparent', : '3px solid transparent',
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
transition: 'background 0.15s, border-color 0.15s', transition: 'background 0.15s, border-color 0.15s',
opacity: isDraggingThis ? 0.4 : 1, opacity: isDraggingThis ? 0.4 : 1,
}} }}
@@ -1490,10 +1577,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.reservation_time?.includes('T') && ( {res.reservation_time?.includes('T') && (
<span style={{ fontWeight: 400 }}> <span style={{ fontWeight: 400 }}>
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{res.reservation_end_time && ` ${(() => { {res.reservation_end_time && ` ${res.reservation_end_time}`}
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
})()}`}
</span> </span>
)} )}
{(() => { {(() => {
@@ -1638,12 +1722,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return ( return (
<React.Fragment key={`transport-${res.id}-${day.id}`}> <React.Fragment key={`transport-${res.id}-${day.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div <div
onClick={() => { onClick={() => canEditDays && onEditTransport?.(res)}
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
else onEditReservation?.(res)
}}
onDragOver={e => { onDragOver={e => {
e.preventDefault(); e.stopPropagation() e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect() const rect = e.currentTarget.getBoundingClientRect()
@@ -1690,8 +1771,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
margin: '1px 8px', margin: '1px 8px',
borderRadius: 6, borderRadius: 6,
border: `1px solid ${color}33`, border: `1px solid ${color}33`,
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined,
background: `${color}08`, background: `${color}08`,
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none', cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
transition: 'background 0.1s', transition: 'background 0.1s',
@@ -1765,6 +1844,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
) )
})()} })()}
</div> </div>
{showDropLineAfter && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
</React.Fragment> </React.Fragment>
) )
} }
@@ -1775,6 +1855,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const noteIdx = idx const noteIdx = idx
return ( return (
<React.Fragment key={`note-${note.id}`}> <React.Fragment key={`note-${note.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div <div
draggable={canEditDays} draggable={canEditDays}
onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} onDragStart={e => { if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
@@ -1830,7 +1911,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
margin: '1px 8px', margin: '1px 8px',
borderRadius: 6, borderRadius: 6,
border: '1px solid var(--border-faint)', border: '1px solid var(--border-faint)',
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
background: 'var(--bg-hover)', background: 'var(--bg-hover)',
opacity: draggingId === `note-${note.id}` ? 0.4 : 1, opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none', transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
@@ -2141,7 +2221,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.notes && ( {res.notes && (
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}> <div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div> <div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div> <div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
</div> </div>
)} )}
@@ -360,25 +360,6 @@ export default function PlaceFormModal({
onClose={onClose} onClose={onClose}
title={place ? t('places.editPlace') : t('places.addPlace')} title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg" size="lg"
footer={
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
}
> >
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}> <form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
{/* Place Search */} {/* Place Search */}
@@ -632,6 +613,23 @@ export default function PlaceFormModal({
</div> </div>
)} )}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
</form> </form>
</Modal> </Modal>
) )
@@ -2,7 +2,6 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
@@ -350,8 +349,8 @@ export default function PlaceInspector({
{/* Notes */} {/* Notes */}
{place.notes && ( {place.notes && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}> <div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
</div> </div>
)} )}
@@ -400,7 +399,7 @@ export default function PlaceInspector({
</div> </div>
)} )}
</div> </div>
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>} {res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
{(() => { {(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null if (!meta || Object.keys(meta).length === 0) return null
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react' import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
@@ -34,8 +34,6 @@ interface PlacesSidebarProps {
onCategoryFilterChange?: (categoryIds: Set<string>) => void onCategoryFilterChange?: (categoryIds: Set<string>) => void
onPlacesFilterChange?: (filter: string) => void onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
} }
interface MemoPlaceRowProps { interface MemoPlaceRowProps {
@@ -147,7 +145,6 @@ const MemoPlaceRow = React.memo(function MemoPlaceRow({
const PlacesSidebar = React.memo(function PlacesSidebar({ const PlacesSidebar = React.memo(function PlacesSidebar({
tripId, places, categories, assignments, selectedDayId, selectedPlaceId, tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
initialScrollTop, onScrollTopChange,
}: PlacesSidebarProps) { }: PlacesSidebarProps) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast() const toast = useToast()
@@ -162,12 +159,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null) const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
const [sidebarDragOver, setSidebarDragOver] = useState(false) const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0) const sidebarDragCounter = useRef(0)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const handleSidebarDragEnter = (e: React.DragEvent) => { const handleSidebarDragEnter = (e: React.DragEvent) => {
if (!canEditPlaces) return if (!canEditPlaces) return
@@ -645,7 +636,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
)} )}
{/* Liste */} {/* Liste */}
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> <div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}> <span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
@@ -1,4 +1,4 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052 // FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-035
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import { render, screen, waitFor, fireEvent } 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';
@@ -203,10 +203,8 @@ describe('ReservationModal', () => {
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } }); fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
fireEvent.change(timePickers[1], { target: { value: '09:00' } }); fireEvent.change(timePickers[1], { target: { value: '09:00' } });
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly. // When isEndBeforeStart=true the submit button is disabled, so submit the form directly
// The Save button now lives in the Modal's sticky footer (outside the <form>), so we query const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
// the form by tag instead of walking up from the button.
const form = document.querySelector('form')!;
fireEvent.submit(form); fireEvent.submit(form);
expect(onSave).not.toHaveBeenCalled(); expect(onSave).not.toHaveBeenCalled();
@@ -723,103 +721,4 @@ describe('ReservationModal', () => {
expect.objectContaining({ type: 'hotel' }) expect.objectContaining({ type: 'hotel' })
); );
}); });
// ── Hotel day-range picker — non-monotonic IDs (issue #929) ───────────────
// Mirrors DayDetailPanel-056/057 for the ReservationModal path.
// ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
function buildNonMonotonicDaysRM() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30', day_number: 1 }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01', day_number: 2 }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02', day_number: 3 }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03', day_number: 4 }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04', day_number: 5 }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05', day_number: 6 }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06', day_number: 7 }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07', day_number: 8 }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08', day_number: 9 }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09', day_number: 10 }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10', day_number: 11 }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11', day_number: 12 }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12', day_number: 13 }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13', day_number: 14 }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14', day_number: 15 }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15', day_number: 16 }),
] as any[];
}
it('FE-PLANNER-RESMODAL-050: non-monotonic IDs — end picker with low ID does not clobber start', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
// Switch to hotel type
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Overlap Hotel');
// Open start picker (first "Select day" trigger) and select Day 1 (id=17)
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || b.textContent?.startsWith('Day '))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 1') && !b.textContent?.startsWith('Day 1 ') || b.textContent?.trim() === 'Day 1')!);
// Open end picker and select Day 16 (id=7, low ID but last positionally)
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
// start must stay id=17 (Day 1) — old Math.max would clobber it to id=7
expect(saved.create_accommodation?.start_day_id).toBe(17);
expect(saved.create_accommodation?.end_day_id).toBe(7);
});
it('FE-PLANNER-RESMODAL-051: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Span Hotel');
// Set end to Day 16 (id=7) first
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to Day 9 (id=25, high ID but earlier by position than Day 16)
// Old code: Math.max(25, 7) = 25 → end collapses to Day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays id=7.
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
expect(saved.create_accommodation?.start_day_id).toBe(25); // Day 9
expect(saved.create_accommodation?.end_day_id).toBe(7); // Day 16 — must NOT have collapsed
});
it('FE-PLANNER-RESMODAL-052: hotel with no accommodation_id sends assignment_id as null (issue #934)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
// Hotel reservation with assignment_id set but no accommodation
const res = buildReservation({
id: 10, title: 'Stale Hotel', type: 'hotel', status: 'confirmed',
accommodation_id: null, assignment_id: 99,
} as any);
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].assignment_id).toBeNull();
});
}); });
@@ -143,18 +143,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} }
}, [reservation, isOpen, selectedDayId, defaultAssignmentId]) }, [reservation, isOpen, selectedDayId, defaultAssignmentId])
// Re-hydrate hotel day range when the accommodations prop arrives after the modal opens
// (race: tripAccommodations fetch may complete after isOpen fires, leaving hotel fields empty)
useEffect(() => {
if (!isOpen || !reservation || reservation.type !== 'hotel' || !reservation.accommodation_id) return
const acc = accommodations.find(a => a.id == reservation.accommodation_id)
if (!acc) return
setForm(prev => {
if (prev.hotel_place_id !== '' || prev.hotel_start_day !== '' || prev.hotel_end_day !== '') return prev
return { ...prev, hotel_place_id: acc.place_id, hotel_start_day: acc.start_day_id, hotel_end_day: acc.end_day_id }
})
}, [accommodations, isOpen, reservation])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value })) const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const isEndBeforeStart = (() => { const isEndBeforeStart = (() => {
@@ -182,8 +170,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
let combinedEndTime = form.reservation_end_time let combinedEndTime = form.reservation_end_time
if (form.end_date) { if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
} else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
} }
if (isBudgetEnabled) { if (isBudgetEnabled) {
if (form.price) metadata.price = form.price if (form.price) metadata.price = form.price
@@ -196,7 +182,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null), reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
location: form.location, confirmation_number: form.confirmation_number, location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes, notes: form.notes,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null), assignment_id: form.assignment_id || null,
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null, accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null, metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: [], endpoints: [],
@@ -207,9 +193,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' } ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 } : { total_price: 0 }
} }
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) { if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = { saveData.create_accommodation = {
place_id: form.hotel_place_id || null, place_id: form.hotel_place_id,
start_day_id: form.hotel_start_day, start_day_id: form.hotel_start_day,
end_day_id: form.hotel_end_day, end_day_id: form.hotel_end_day,
check_in: form.meta_check_in_time || null, check_in: form.meta_check_in_time || null,
@@ -273,22 +259,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' } const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
return ( return (
<Modal <Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
isOpen={isOpen}
onClose={onClose}
title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')}
size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Type selector */} {/* Type selector */}
@@ -434,17 +405,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<CustomSelect <CustomSelect
value={form.hotel_place_id} value={form.hotel_place_id}
onChange={value => { onChange={value => {
set('hotel_place_id', value)
const p = places.find(pl => pl.id === value) const p = places.find(pl => pl.id === value)
setForm(prev => { if (p) {
const next = { ...prev, hotel_place_id: value } if (!form.title) set('title', p.name)
if (!value) { if (!form.location && p.address) set('location', p.address)
next.location = '' }
} else if (p) {
if (!prev.title) next.title = p.name
if (!prev.location && p.address) next.location = p.address
}
return next
})
}} }}
placeholder={t('reservations.meta.pickHotel')} placeholder={t('reservations.meta.pickHotel')}
options={[ options={[
@@ -459,22 +425,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label> <label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
<CustomSelect <CustomSelect
value={form.hotel_start_day} value={form.hotel_start_day}
onChange={value => setForm(prev => ({ onChange={value => set('hotel_start_day', value)}
...prev,
hotel_start_day: value,
hotel_end_day: days.findIndex(d => d.id === value) > days.findIndex(d => d.id === prev.hotel_end_day)
? value : prev.hotel_end_day,
}))}
placeholder={t('reservations.meta.selectDay')} placeholder={t('reservations.meta.selectDay')}
options={days.map(d => { options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
return {
value: d.id,
label: d.title || t('dayplan.dayN', { n: d.day_number }),
badge: dateBadge ?? dayBadge,
}
})}
size="sm" size="sm"
/> />
</div> </div>
@@ -482,22 +435,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.toDay')}</label> <label style={labelStyle}>{t('reservations.meta.toDay')}</label>
<CustomSelect <CustomSelect
value={form.hotel_end_day} value={form.hotel_end_day}
onChange={value => setForm(prev => ({ onChange={value => set('hotel_end_day', value)}
...prev,
hotel_start_day: days.findIndex(d => d.id === value) < days.findIndex(d => d.id === prev.hotel_start_day)
? value : prev.hotel_start_day,
hotel_end_day: value,
}))}
placeholder={t('reservations.meta.selectDay')} placeholder={t('reservations.meta.selectDay')}
options={days.map(d => { options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
return {
value: d.id,
label: d.title || t('dayplan.dayN', { n: d.day_number }),
badge: dateBadge ?? dayBadge,
}
})}
size="sm" size="sm"
/> />
</div> </div>
@@ -649,6 +589,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</> </>
)} )}
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim() || isEndBeforeStart} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() || isEndBeforeStart ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form> </form>
</Modal> </Modal>
) )
@@ -11,9 +11,6 @@ import {
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
} from 'lucide-react' } from 'lucide-react'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry { interface AssignmentLookupEntry {
@@ -115,30 +112,17 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const isTransportType = TRANSPORT_TYPES_SET.has(r.type) const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
const isHotel = r.type === 'hotel' const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
const startDay = r.day_id ? days.find(d => d.id === r.day_id) const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id) const dayLabel = (day: typeof startDay): string => {
: undefined if (!day) return ''
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) const base = day.title || t('dayplan.dayN', { n: day.day_number })
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id) if (day.date) {
: undefined const d = new Date(day.date + 'T00:00:00Z')
const DayLabel = ({ day }: { day: typeof startDay }) => { const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
if (!day) return null return `${base} · ${dateStr}`
const name = day.title || t('dayplan.dayN', { n: day.day_number }) }
const badge = day.date return base
? new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
: null
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span>{name}</span>
{badge && (
<span style={{
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 999,
}}>{badge}</span>
)}
</span>
)
} }
return ( return (
@@ -151,15 +135,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'}
onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}
> >
{/* Header wraps to a second row on narrow screens so the status/category chips {/* Header */}
never collide with the title. */}
<div style={{ <div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
flexWrap: 'wrap',
padding: '12px 14px', padding: '12px 14px',
background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)',
}}> }}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}> <div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<span style={{ <span style={{
display: 'inline-flex', alignItems: 'center', gap: 6, display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706', fontSize: 12, fontWeight: 600, color: confirmed ? '#16a34a' : '#d97706',
@@ -220,15 +202,12 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{/* Body */} {/* Body */}
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}> <div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
{/* Day label for transport/hotel reservations linked to days */} {/* Day label for transport reservations linked to a day */}
{(isTransportType || isHotel) && startDay && ( {isTransportType && startDay && (
<div> <div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div> <div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}> <div style={{ ...fieldValueStyle, textAlign: 'center' }}>
<DayLabel day={startDay} /> {dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` ${dayLabel(endDay)}` : ''}
{endDay && endDay.id !== startDay.id && (
<><span style={{ color: 'var(--text-faint)' }}></span><DayLabel day={endDay} /></>
)}
</div> </div>
</div> </div>
)} )}
@@ -239,16 +218,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
<div style={fieldLabelStyle}>{t('reservations.date')}</div> <div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}> <div style={{ ...fieldValueStyle, textAlign: 'center' }}>
{fmtDate(r.reservation_time)} {fmtDate(r.reservation_time)}
{(() => { {r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && (
const endDatePart = r.reservation_end_time
? r.reservation_end_time.includes('T')
? r.reservation_end_time.split('T')[0]
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
? r.reservation_end_time
: null
: null
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
})() && (
<> {fmtDate(r.reservation_end_time)}</> <> {fmtDate(r.reservation_end_time)}</>
)} )}
</div> </div>
@@ -367,9 +337,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{r.notes && ( {r.notes && (
<div> <div>
<div style={fieldLabelStyle}>{t('reservations.notes')}</div> <div style={fieldLabelStyle}>{t('reservations.notes')}</div>
<div className="collab-note-md" style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}> <div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
</div>
</div> </div>
)} )}
@@ -1,324 +0,0 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
buildTrip,
buildDay,
buildReservation,
buildTripFile,
} from '../../../tests/helpers/factories';
import { TransportModal } from './TransportModal';
vi.mock('react-router-dom', async (importActual) => {
const actual = await importActual<typeof import('react-router-dom')>();
return { ...actual, useParams: () => ({ id: '1' }) };
});
vi.mock('../shared/CustomTimePicker', () => ({
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<input data-testid="time-picker" type="text" value={value} onChange={e => onChange(e.target.value)} />
),
}));
vi.mock('./AirportSelect', () => ({
default: ({ onChange }: { onChange: (a: any) => void }) => (
<input data-testid="airport-select" type="text" onChange={e => onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} />
),
}));
vi.mock('./LocationSelect', () => ({
default: ({ onChange }: { onChange: (l: any) => void }) => (
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} />
),
}));
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
reservation: null,
days: [],
selectedDayId: null,
files: [],
onFileUpload: vi.fn().mockResolvedValue(undefined),
onFileDelete: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] });
vi.clearAllMocks();
});
describe('TransportModal', () => {
// ── Rendering ──────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => {
render(<TransportModal {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => {
render(<TransportModal {...defaultProps} reservation={null} />);
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
});
it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => {
const res = buildReservation({ title: 'My Train', type: 'train' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => {
const onClose = vi.fn();
render(<TransportModal {...defaultProps} onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }));
});
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }));
});
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<TransportModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
);
});
// ── File attachment ───────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => {
render(<TransportModal {...defaultProps} onFileUpload={undefined} />);
expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => {
const res = buildReservation({ id: 5, type: 'flight' });
const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' });
(file as any).reservation_id = 5;
render(<TransportModal {...defaultProps} reservation={res} files={[file]} />);
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument());
});
it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => {
const onFileUpload = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' });
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
const [fd] = onFileUpload.mock.calls[0] as [FormData];
expect(fd.get('file')).toBeTruthy();
expect(fd.get('reservation_id')).toBe('10');
});
it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => {
server.use(
http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
);
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await userEvent.click(screen.getByText('invoice.pdf'));
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument();
});
});
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument());
const pendingFileRow = screen.getByText('draft.pdf').closest('div')!;
const removeBtn = pendingFileRow.querySelector('button')!;
await userEvent.click(removeBtn);
await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument());
});
it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => {
render(<TransportModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await userEvent.click(attachBtn);
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => {
server.use(
http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })),
http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
);
const res = buildReservation({ id: 7, type: 'car' });
const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[looseFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument());
await userEvent.click(screen.getByText('rental-agreement.pdf'));
await waitFor(() =>
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument()
);
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
await userEvent.click(unlinkBtn);
await waitFor(() => {
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
});
it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => {
const savedReservation = buildReservation({ id: 99, type: 'flight' });
const onSave = vi.fn().mockResolvedValue(savedReservation);
const onFileUpload = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
const [fd] = onFileUpload.mock.calls[0] as [FormData];
expect(fd.get('reservation_id')).toBe('99');
expect(fd.get('file')).toBeTruthy();
});
});
+19 -223
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo, useRef } from 'react' import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom' import { Plane, Train, Car, Ship } from 'lucide-react'
import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
@@ -8,12 +7,8 @@ import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect' import LocationSelect, { type LocationPoint } from './LocationSelect'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters' import { formatDate } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload' import type { Day, Reservation, ReservationEndpoint } from '../../types'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
type TransportType = typeof TRANSPORT_TYPES[number] type TransportType = typeof TRANSPORT_TYPES[number]
@@ -80,8 +75,6 @@ const defaultForm = {
arrival_time: '', arrival_time: '',
confirmation_number: '', confirmation_number: '',
notes: '', notes: '',
price: '',
budget_category: '',
meta_airline: '', meta_airline: '',
meta_flight_number: '', meta_flight_number: '',
meta_train_number: '', meta_train_number: '',
@@ -92,36 +85,19 @@ const defaultForm = {
interface TransportModalProps { interface TransportModalProps {
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onSave: (data: Record<string, any>) => Promise<Reservation | undefined> onSave: (data: Record<string, any>) => Promise<void>
reservation: Reservation | null reservation: Reservation | null
days: Day[] days: Day[]
selectedDayId: number | null selectedDayId: number | null
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<void>
onFileDelete?: (fileId: number) => Promise<void>
} }
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) { export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const loadFiles = useTripStore(s => s.loadFiles)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const { id: tripId } = useParams<{ id: string }>()
const [form, setForm] = useState({ ...defaultForm }) const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({}) const [toPick, setToPick] = useState<EndpointPick>({})
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@@ -150,8 +126,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '', meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '', meta_platform: meta.platform || '',
meta_seat: meta.seat || '', meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
if (type === 'flight') { if (type === 'flight') {
setFromPick({ airport: airportFromEndpoint(from) || undefined }) setFromPick({ airport: airportFromEndpoint(from) || undefined })
@@ -165,7 +139,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({}) setFromPick({})
setToPick({}) setToPick({})
} }
}, [isOpen, reservation, selectedDayId, budgetItems]) }, [isOpen, reservation, selectedDayId])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value })) const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
@@ -199,10 +173,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat if (form.meta_seat) metadata.seat = form.meta_seat
} }
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null const endDate = (endDay ?? startDay)?.date ?? null
@@ -230,21 +200,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints, endpoints,
needs_review: false, needs_review: false,
} }
if (isBudgetEnabled) { await onSave(payload)
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(saved.id))
fd.append('description', form.title)
await onFileUpload(fd)
}
}
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError')) toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally { } finally {
@@ -252,38 +208,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
} }
} }
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(reservation.id))
fd.append('description', reservation.title)
await onFileUpload!(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id
? files.filter(f =>
f.reservation_id === reservation.id ||
linkedFileIds.includes(f.id) ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
)
: []
const inputStyle = { const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit', padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
@@ -296,15 +220,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const dayOptions = [ const dayOptions = [
{ value: '', label: '—' }, { value: '', label: '—' },
...days.map(d => { ...days.map(d => ({
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined value: d.id,
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`,
return { })),
value: d.id,
label: d.title || t('dayplan.dayN', { n: d.day_number }),
badge: dateBadge ?? dayBadge,
}
}),
] ]
return ( return (
@@ -313,16 +232,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
onClose={onClose} onClose={onClose}
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')} title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
size="2xl" size="2xl"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button type="button" onClick={handleSubmit} disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
}
> >
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
@@ -498,128 +407,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} /> style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div> </div>
{/* Files */} {/* Actions */}
<div> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
<label style={labelStyle}>{t('files.title')}</label> <button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}> {t('common.cancel')}
{attachedFiles.map(f => ( </button>
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}> <button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} /> {isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span> </button>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
}
try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
} catch {}
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
if (tripId) loadFiles(tripId)
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
{pendingFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
}}>
<Paperclip size={11} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={11} /> {t('reservations.linkExisting')}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
}}>
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
<button key={f.id} type="button" onClick={async () => {
try {
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
setLinkedFileIds(prev => [...prev, f.id])
setShowFilePicker(false)
if (tripId) loadFiles(tripId)
} catch {}
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
</div> </div>
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
style={inputStyle} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label style={labelStyle}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)}
</form> </form>
</Modal> </Modal>
) )
@@ -155,9 +155,7 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined); const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting }); seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }), updateSetting });
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
// The label is split across a text node ('24h') and a responsive span (' (14:30)'). await user.click(screen.getByText('24h (14:30)'));
// Click the button that contains the 24h text instead of matching the full string.
await user.click(screen.getByRole('button', { name: /24h/ }));
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h'); expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
}); });
@@ -188,8 +188,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label> <label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
<div className="flex gap-3"> <div className="flex gap-3">
{[ {[
{ value: '24h', short: '24h', example: '14:30' }, { value: '24h', label: '24h (14:30)' },
{ value: '12h', short: '12h', example: '2:30 PM' }, { value: '12h', label: '12h (2:30 PM)' },
].map(opt => ( ].map(opt => (
<button <button
key={opt.value} key={opt.value}
@@ -207,8 +207,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
transition: 'all 0.15s', transition: 'all 0.15s',
}} }}
> >
{opt.short} {opt.label}
<span className="hidden sm:inline">{` (${opt.example})`}</span>
</button> </button>
))} ))}
</div> </div>
@@ -240,18 +240,14 @@ export default function MapSettingsTab(): React.ReactElement {
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700' : 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`} }`}
> >
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" /> <span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
<span className="sm:hidden">Mapbox</span>
<span className="hidden sm:inline">Mapbox GL</span>
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
{/* Experimental badge only on ≥sm; on mobile there's no room next to the title. */}
<span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')} {t('settings.mapExperimental')}
</span> </span>
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div>
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
</div>
</button> </button>
</div> </div>
<p className="text-xs text-slate-400 mt-2"> <p className="text-xs text-slate-400 mt-2">
@@ -25,7 +25,6 @@ const EVENT_LABEL_KEYS: Record<string, string> = {
trip_invite: 'settings.notifyTripInvite', trip_invite: 'settings.notifyTripInvite',
booking_change: 'settings.notifyBookingChange', booking_change: 'settings.notifyBookingChange',
trip_reminder: 'settings.notifyTripReminder', trip_reminder: 'settings.notifyTripReminder',
todo_due: 'settings.notifyTodoDue',
vacay_invite: 'settings.notifyVacayInvite', vacay_invite: 'settings.notifyVacayInvite',
photos_shared: 'settings.notifyPhotosShared', photos_shared: 'settings.notifyPhotosShared',
collab_message: 'settings.notifyCollabMessage', collab_message: 'settings.notifyCollabMessage',
@@ -5,7 +5,6 @@ import { useToast } from '../../components/shared/Toast'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import Section from './Section' import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
interface ProviderField { interface ProviderField {
key: string key: string
@@ -223,13 +222,15 @@ export default function PhotoProvidersSection(): React.ReactElement {
{fields.map(field => ( {fields.map(field => (
<div key={`${provider.id}-${field.key}`}> <div key={`${provider.id}-${field.key}`}>
{field.input_type === 'checkbox' ? ( {field.input_type === 'checkbox' ? (
<div className="flex items-center gap-3"> <label className="flex items-center gap-2 cursor-pointer select-none">
<ToggleSwitch <input
on={values[field.key] === 'true'} type="checkbox"
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')} checked={values[field.key] === 'true'}
onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.checked ? 'true' : 'false')}
className="w-4 h-4 rounded border-slate-300 accent-slate-900"
/> />
<span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span> <span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span>
</div> </label>
) : ( ) : (
<> <>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label> <label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label>
@@ -247,9 +248,7 @@ export default function PhotoProvidersSection(): React.ReactElement {
)} )}
</div> </div>
))} ))}
{/* Wraps on mobile so the connection badge drops to its own row <div className="flex items-center gap-3">
instead of clipping off the side of the card. */}
<div className="flex flex-wrap items-center gap-3">
<button <button
onClick={() => handleSaveProvider(provider)} onClick={() => handleSaveProvider(provider)}
disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)} disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)}
@@ -267,17 +266,15 @@ export default function PhotoProvidersSection(): React.ReactElement {
{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" />}
<span className="sm:hidden">{t('memories.testShort')}</span> {t('memories.testConnection')}
<span className="hidden sm:inline">{t('memories.testConnection')}</span>
</button> </button>
{/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */}
{connected ? ( {connected ? (
<span className="basis-full sm:basis-auto 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="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1"> <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" /> <span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('memories.disconnected')} {t('memories.disconnected')}
</span> </span>
@@ -2,10 +2,9 @@ import React from 'react'
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) { export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
return ( return (
<button type="button" onClick={onToggle} <button onClick={onToggle}
style={{ style={{
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0, position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)', background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
transition: 'background 0.2s', transition: 'background 0.2s',
}}> }}>
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
return ( return (
<div <div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter" className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }} style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
onClick={onClose} onClick={onClose}
> >
<div <div
@@ -1,108 +0,0 @@
import React, { useEffect, useCallback } from 'react'
import { Check, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface CopyTripDialogProps {
isOpen: boolean
tripTitle: string
onClose: () => void
onConfirm: () => void
}
const WILL_COPY_KEYS = [
'dashboard.confirm.copy.will1',
'dashboard.confirm.copy.will2',
'dashboard.confirm.copy.will3',
'dashboard.confirm.copy.will4',
'dashboard.confirm.copy.will5',
'dashboard.confirm.copy.will6',
]
const WONT_COPY_KEYS = [
'dashboard.confirm.copy.wont1',
'dashboard.confirm.copy.wont2',
'dashboard.confirm.copy.wont3',
'dashboard.confirm.copy.wont4',
]
export default function CopyTripDialog({ isOpen, tripTitle, onClose, onConfirm }: CopyTripDialogProps) {
const { t } = useTranslation()
const handleEsc = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [isOpen, handleEsc])
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
onClick={onClose}
>
<div
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-md p-6"
style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()}
>
<h3 className="text-base font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
{t('dashboard.confirm.copy.title')}
</h3>
<p className="text-sm mb-4" style={{ color: 'var(--text-secondary)' }}>
{tripTitle}
</p>
<div className="flex flex-col gap-3">
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: '#16a34a' }}>
{t('dashboard.confirm.copy.willCopy')}
</p>
<ul className="flex flex-col gap-1">
{WILL_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<Check size={13} className="flex-shrink-0" style={{ color: '#16a34a' }} />
{t(key)}
</li>
))}
</ul>
</div>
<div className="rounded-xl p-3" style={{ background: 'var(--bg-subtle)', border: '1px solid var(--border-secondary)' }}>
<p className="text-xs font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-muted)' }}>
{t('dashboard.confirm.copy.wontCopy')}
</p>
<ul className="flex flex-col gap-1">
{WONT_COPY_KEYS.map(key => (
<li key={key} className="flex items-center gap-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
<X size={13} className="flex-shrink-0" style={{ color: 'var(--text-muted)' }} />
{t(key)}
</li>
))}
</ul>
</div>
</div>
<div className="flex justify-end gap-3 mt-5">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
style={{ color: 'var(--text-secondary)', border: '1px solid var(--border-secondary)' }}
>
{t('common.cancel')}
</button>
<button
onClick={() => { onConfirm(); onClose() }}
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white bg-blue-600 hover:bg-blue-700"
>
{t('dashboard.confirm.copy.confirm')}
</button>
</div>
</div>
</div>
)
}
@@ -119,14 +119,13 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
...(() => { ...(() => {
const r = ref.current?.getBoundingClientRect() const r = ref.current?.getBoundingClientRect()
if (!r) return { top: 0, left: 0 } if (!r) return { top: 0, left: 0 }
const w = 268, pad = 8, h = 360 const w = 268, pad = 8
const vw = window.innerWidth const vw = window.innerWidth
const vh = window.visualViewport?.height ?? window.innerHeight const vh = window.innerHeight
let left = r.left let left = r.left
let top = r.bottom + 4 let top = r.bottom + 4
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad) if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
if (top + h > vh - pad) top = r.top - h - 4 if (top + 320 > vh) top = Math.max(pad, r.top - 320)
top = Math.max(pad, Math.min(top, vh - h - pad))
if (vw < 360) left = Math.max(pad, (vw - w) / 2) if (vw < 360) left = Math.max(pad, (vw - w) / 2)
return { top, left } return { top, left }
})(), })(),
@@ -9,7 +9,6 @@ interface SelectOption {
isHeader?: boolean isHeader?: boolean
searchLabel?: string searchLabel?: string
groupLabel?: string groupLabel?: string
badge?: string
} }
interface CustomSelectProps { interface CustomSelectProps {
@@ -105,13 +104,6 @@ export default function CustomSelect({
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{selected ? selected.label : placeholder} {selected ? selected.label : placeholder}
</span> </span>
{selected?.badge && (
<span style={{
flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
letterSpacing: '0.01em',
}}>{selected.badge}</span>
)}
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} /> <ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
</button> </button>
@@ -194,13 +186,6 @@ export default function CustomSelect({
> >
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>} {option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
{option.badge && (
<span style={{
flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
letterSpacing: '0.01em',
}}>{option.badge}</span>
)}
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />} {isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
</button> </button>
) )
+8 -9
View File
@@ -61,15 +61,14 @@ export default function Modal({
<div <div
className={` className={`
trek-modal-enter trek-modal-enter
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md} rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
`} `}
style={{ background: 'var(--bg-card)' }} style={{ background: 'var(--bg-card)' }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
{/* Header — stays put even while the body scrolls */} {/* Header */}
<div className="flex items-center justify-between p-6 flex-shrink-0" style={{ borderBottom: '1px solid var(--border-secondary)' }}> <div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2> <h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{!hideCloseButton && ( {!hideCloseButton && (
<button <button
@@ -81,14 +80,14 @@ export default function Modal({
)} )}
</div> </div>
{/* Body — scrolls when content overflows. min-h-0 lets the flex child shrink below its intrinsic height. */} {/* Body */}
<div className="flex-1 overflow-y-auto p-6 min-h-0"> <div className="flex-1 overflow-y-auto p-6">
{children} {children}
</div> </div>
{/* Footer — sticky at the bottom of the modal, never compressed */} {/* Footer */}
{footer && ( {footer && (
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}> <div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{footer} {footer}
</div> </div>
)} )}
+3 -16
View File
@@ -34,8 +34,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'لا شيء', 'common.none': 'لا شيء',
'common.date': 'التاريخ', 'common.date': 'التاريخ',
'common.rename': 'إعادة تسمية', 'common.rename': 'إعادة تسمية',
'common.discardChanges': 'تجاهل التغييرات',
'common.discard': 'تجاهل',
'common.name': 'الاسم', 'common.name': 'الاسم',
'common.email': 'البريد الإلكتروني', 'common.email': 'البريد الإلكتروني',
'common.password': 'كلمة المرور', 'common.password': 'كلمة المرور',
@@ -206,7 +204,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'دعوات الرحلات', 'settings.notifyTripInvite': 'دعوات الرحلات',
'settings.notifyBookingChange': 'تغييرات الحجز', 'settings.notifyBookingChange': 'تغييرات الحجز',
'settings.notifyTripReminder': 'تذكيرات الرحلات', 'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات', 'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyPhotosShared': 'صور مشتركة (Immich)', 'settings.notifyPhotosShared': 'صور مشتركة (Immich)',
'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)', 'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)',
@@ -1224,8 +1221,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'الملفات', 'files.title': 'الملفات',
'files.pageTitle': 'الملفات والمستندات', 'files.pageTitle': 'الملفات والمستندات',
'files.subtitle': '{count} ملف لـ {trip}', 'files.subtitle': '{count} ملف لـ {trip}',
'files.download': 'تنزيل',
'files.openError': 'تعذر فتح الملف',
'files.downloadPdf': 'تنزيل PDF', 'files.downloadPdf': 'تنزيل PDF',
'files.count': '{count} ملفات', 'files.count': '{count} ملفات',
'files.countSingular': 'ملف واحد', 'files.countSingular': 'ملف واحد',
@@ -1249,7 +1244,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'فشل حذف الملف', 'files.toast.deleteError': 'فشل حذف الملف',
'files.sourcePlan': 'خطة اليوم', 'files.sourcePlan': 'خطة اليوم',
'files.sourceBooking': 'الحجز', 'files.sourceBooking': 'الحجز',
'files.sourceTransport': 'النقل',
'files.attach': 'إرفاق', 'files.attach': 'إرفاق',
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)', 'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
'files.trash': 'سلة المهملات', 'files.trash': 'سلة المهملات',
@@ -1262,7 +1256,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'إسناد ملف', 'files.assignTitle': 'إسناد ملف',
'files.assignPlace': 'المكان', 'files.assignPlace': 'المكان',
'files.assignBooking': 'الحجز', 'files.assignBooking': 'الحجز',
'files.assignTransport': 'النقل',
'files.unassigned': 'غير مسند', 'files.unassigned': 'غير مسند',
'files.unlink': 'إزالة الرابط', 'files.unlink': 'إزالة الرابط',
'files.toast.trashed': 'تم النقل إلى سلة المهملات', 'files.toast.trashed': 'تم النقل إلى سلة المهملات',
@@ -1627,7 +1620,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع', 'memories.immichAutoUpload': 'نسخ صور الرحلة إلى Immich عند الرفع',
'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo', 'memories.providerUrlHintSynology': 'أدرج مسار تطبيق Photos في URL، مثل https://nas:5001/photo',
'memories.testConnection': 'اختبار الاتصال', 'memories.testConnection': 'اختبار الاتصال',
'memories.testShort': 'اختبار',
'memories.testFirst': 'اختبر الاتصال أولاً', 'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل', 'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل', 'memories.disconnected': 'غير متصل',
@@ -2003,8 +1995,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}', 'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
'notif.trip_reminder.title': 'تذكير بالرحلة', 'notif.trip_reminder.title': 'تذكير بالرحلة',
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!', 'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
'notif.todo_due.title': 'مهمة مستحقة',
'notif.todo_due.text': '{todo} في {trip} مستحقة في {due}',
'notif.vacay_invite.title': 'دعوة دمج الإجازة', 'notif.vacay_invite.title': 'دعوة دمج الإجازة',
'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة', 'notif.vacay_invite.text': '{actor} يدعوك لدمج خطط الإجازة',
'notif.photos_shared.title': 'تمت مشاركة الصور', 'notif.photos_shared.title': 'تمت مشاركة الصور',
@@ -2143,12 +2133,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'كلمة شخصية مني', 'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.', 'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'إجراء مطلوب: تعارض في حسابات المستخدمين', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'اكتشف ترقية 3.0.14 تعارضًا في أسماء مستخدمين أو بريد إلكتروني ناتجًا عن مسافات بيضاء في بداية أو نهاية القيم المخزنة. تمت إعادة تسمية الحسابات المتأثرة تلقائيًا. تحقق من سجلات الخادم بحثًا عن أسطر تبدأ بـ **[migration] WHITESPACE COLLISION** لتحديد الحسابات التي تحتاج إلى مراجعة.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'إضافة وسيلة نقل',
'transport.modalTitle.create': 'إضافة وسيلة نقل',
'transport.modalTitle.edit': 'تعديل وسيلة النقل',
'transport.title': 'المواصلات', 'transport.title': 'المواصلات',
'transport.addManual': 'نقل يدوي', 'transport.addManual': 'نقل يدوي',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nenhum', 'common.none': 'Nenhum',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Renomear', 'common.rename': 'Renomear',
'common.discardChanges': 'Descartar alterações',
'common.discard': 'Descartar',
'common.name': 'Nome', 'common.name': 'Nome',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Senha', 'common.password': 'Senha',
@@ -201,7 +199,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Convites de viagem', 'settings.notifyTripInvite': 'Convites de viagem',
'settings.notifyBookingChange': 'Alterações de reserva', 'settings.notifyBookingChange': 'Alterações de reserva',
'settings.notifyTripReminder': 'Lembretes de viagem', 'settings.notifyTripReminder': 'Lembretes de viagem',
'settings.notifyTodoDue': 'Tarefa com vencimento',
'settings.notifyVacayInvite': 'Convites de fusão Vacay', 'settings.notifyVacayInvite': 'Convites de fusão Vacay',
'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)', 'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)',
'settings.notifyCollabMessage': 'Mensagens de chat (Colab)', 'settings.notifyCollabMessage': 'Mensagens de chat (Colab)',
@@ -1193,8 +1190,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Arquivos', 'files.title': 'Arquivos',
'files.pageTitle': 'Arquivos e documentos', 'files.pageTitle': 'Arquivos e documentos',
'files.subtitle': '{count} arquivos para {trip}', 'files.subtitle': '{count} arquivos para {trip}',
'files.download': 'Baixar',
'files.openError': 'Não foi possível abrir o arquivo',
'files.downloadPdf': 'Baixar PDF', 'files.downloadPdf': 'Baixar PDF',
'files.count': '{count} arquivos', 'files.count': '{count} arquivos',
'files.countSingular': '1 arquivo', 'files.countSingular': '1 arquivo',
@@ -1218,7 +1213,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Falha ao excluir arquivo', 'files.toast.deleteError': 'Falha ao excluir arquivo',
'files.sourcePlan': 'Plano do dia', 'files.sourcePlan': 'Plano do dia',
'files.sourceBooking': 'Reserva', 'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Anexar', 'files.attach': 'Anexar',
'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)', 'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)',
'files.trash': 'Lixeira', 'files.trash': 'Lixeira',
@@ -1231,7 +1225,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Atribuir arquivo', 'files.assignTitle': 'Atribuir arquivo',
'files.assignPlace': 'Lugar', 'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva', 'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Não atribuído', 'files.unassigned': 'Não atribuído',
'files.unlink': 'Remover vínculo', 'files.unlink': 'Remover vínculo',
'files.toast.trashed': 'Movido para a lixeira', 'files.toast.trashed': 'Movido para a lixeira',
@@ -1666,7 +1659,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar', 'memories.immichAutoUpload': 'Espelhar fotos da jornada no Immich ao enviar',
'memories.providerUrlHintSynology': 'Inclua o caminho do aplicativo Photos na URL, ex. https://nas:5001/photo', '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.testShort': 'Testar',
'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',
@@ -1943,8 +1935,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}', 'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
'notif.trip_reminder.title': 'Lembrete de viagem', 'notif.trip_reminder.title': 'Lembrete de viagem',
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!', 'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
'notif.todo_due.title': 'Tarefa com vencimento',
'notif.todo_due.text': '{todo} em {trip} vence em {due}',
'notif.vacay_invite.title': 'Convite Vacay Fusion', 'notif.vacay_invite.title': 'Convite Vacay Fusion',
'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias', 'notif.vacay_invite.text': '{actor} convidou você para fundir planos de férias',
'notif.photos_shared.title': 'Fotos compartilhadas', 'notif.photos_shared.title': 'Fotos compartilhadas',
@@ -2346,12 +2336,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha', 'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.', 'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Ação necessária: conflito de conta de usuário', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'A atualização 3.0.14 detectou um ou mais conflitos de nome de usuário ou e-mail causados por espaços em branco no início ou fim dos valores armazenados. As contas afetadas foram renomeadas automaticamente. Verifique os logs do servidor por linhas começando com **[migration] WHITESPACE COLLISION** para identificar quais contas precisam de revisão.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Adicionar transporte',
'transport.modalTitle.create': 'Adicionar transporte',
'transport.modalTitle.edit': 'Editar transporte',
'transport.title': 'Transportes', 'transport.title': 'Transportes',
'transport.addManual': 'Transporte Manual', 'transport.addManual': 'Transporte Manual',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Žádné', 'common.none': 'Žádné',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Přejmenovat', 'common.rename': 'Přejmenovat',
'common.discardChanges': 'Zahodit změny',
'common.discard': 'Zahodit',
'common.name': 'Jméno', 'common.name': 'Jméno',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Heslo', 'common.password': 'Heslo',
@@ -202,7 +200,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Pozvánky na cesty', 'settings.notifyTripInvite': 'Pozvánky na cesty',
'settings.notifyBookingChange': 'Změny rezervací', 'settings.notifyBookingChange': 'Změny rezervací',
'settings.notifyTripReminder': 'Připomínky cest', 'settings.notifyTripReminder': 'Připomínky cest',
'settings.notifyTodoDue': 'Úkol se blíží',
'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay', 'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay',
'settings.notifyPhotosShared': 'Sdílené fotky (Immich)', 'settings.notifyPhotosShared': 'Sdílené fotky (Immich)',
'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)', 'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)',
@@ -1222,8 +1219,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Soubory', 'files.title': 'Soubory',
'files.pageTitle': 'Soubory a dokumenty', 'files.pageTitle': 'Soubory a dokumenty',
'files.subtitle': '{count} souborů pro {trip}', 'files.subtitle': '{count} souborů pro {trip}',
'files.download': 'Stáhnout',
'files.openError': 'Soubor nelze otevřít',
'files.downloadPdf': 'Stáhnout PDF', 'files.downloadPdf': 'Stáhnout PDF',
'files.count': '{count} souborů', 'files.count': '{count} souborů',
'files.countSingular': '1 soubor', 'files.countSingular': '1 soubor',
@@ -1247,7 +1242,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nepodařilo se smazat soubor', 'files.toast.deleteError': 'Nepodařilo se smazat soubor',
'files.sourcePlan': 'Denní plán', 'files.sourcePlan': 'Denní plán',
'files.sourceBooking': 'Rezervace', 'files.sourceBooking': 'Rezervace',
'files.sourceTransport': 'Doprava',
'files.attach': 'Přiložit', 'files.attach': 'Přiložit',
'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)', 'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)',
'files.trash': 'Koš', 'files.trash': 'Koš',
@@ -1260,7 +1254,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Přiřadit soubor', 'files.assignTitle': 'Přiřadit soubor',
'files.assignPlace': 'Místo', 'files.assignPlace': 'Místo',
'files.assignBooking': 'Rezervace', 'files.assignBooking': 'Rezervace',
'files.assignTransport': 'Doprava',
'files.unassigned': 'Nepřiřazeno', 'files.unassigned': 'Nepřiřazeno',
'files.unlink': 'Zrušit propojení', 'files.unlink': 'Zrušit propojení',
'files.toast.trashed': 'Přesunuto do koše', 'files.toast.trashed': 'Přesunuto do koše',
@@ -1625,7 +1618,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich', 'memories.immichAutoUpload': 'Zrcadlit fotky journey při nahrávání také do Immich',
'memories.providerUrlHintSynology': 'Zahrňte cestu aplikace Photos do URL, např. https://nas:5001/photo', '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.testShort': 'Otestovat',
'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',
@@ -1948,8 +1940,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}', 'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}',
'notif.trip_reminder.title': 'Připomínka výletu', 'notif.trip_reminder.title': 'Připomínka výletu',
'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!', 'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!',
'notif.todo_due.title': 'Úkol se blíží',
'notif.todo_due.text': '{todo} ve výletě {trip} má termín {due}',
'notif.vacay_invite.title': 'Pozvánka Vacay Fusion', 'notif.vacay_invite.title': 'Pozvánka Vacay Fusion',
'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů', 'notif.vacay_invite.text': '{actor} vás pozval ke spojení dovolenkových plánů',
'notif.photos_shared.title': 'Fotky sdíleny', 'notif.photos_shared.title': 'Fotky sdíleny',
@@ -2350,12 +2340,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě', 'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.', 'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Vyžadována akce: konflikt uživatelského účtu', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'Aktualizace 3.0.14 zjistila jeden nebo více konfliktů uživatelského jména nebo e-mailu způsobených mezerami na začátku nebo konci uložených hodnot. Dotčené účty byly automaticky přejmenovány. Zkontrolujte protokoly serveru na řádky začínající **[migration] WHITESPACE COLLISION** a zjistěte, které účty vyžadují kontrolu.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Přidat dopravu',
'transport.modalTitle.create': 'Přidat dopravu',
'transport.modalTitle.edit': 'Upravit dopravu',
'transport.title': 'Doprava', 'transport.title': 'Doprava',
'transport.addManual': 'Ruční doprava', 'transport.addManual': 'Ruční doprava',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Keine', 'common.none': 'Keine',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Umbenennen', 'common.rename': 'Umbenennen',
'common.discardChanges': 'Änderungen verwerfen',
'common.discard': 'Verwerfen',
'common.name': 'Name', 'common.name': 'Name',
'common.email': 'E-Mail', 'common.email': 'E-Mail',
'common.password': 'Passwort', 'common.password': 'Passwort',
@@ -206,7 +204,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Trip-Einladungen', 'settings.notifyTripInvite': 'Trip-Einladungen',
'settings.notifyBookingChange': 'Buchungsänderungen', 'settings.notifyBookingChange': 'Buchungsänderungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen', 'settings.notifyTripReminder': 'Trip-Erinnerungen',
'settings.notifyTodoDue': 'Aufgabe bald fällig',
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen', 'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)', 'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)',
'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)', 'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)',
@@ -1226,8 +1223,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Dateien', 'files.title': 'Dateien',
'files.pageTitle': 'Dateien & Dokumente', 'files.pageTitle': 'Dateien & Dokumente',
'files.subtitle': '{count} Dateien für {trip}', 'files.subtitle': '{count} Dateien für {trip}',
'files.download': 'Herunterladen',
'files.openError': 'Datei konnte nicht geöffnet werden',
'files.downloadPdf': 'PDF herunterladen', 'files.downloadPdf': 'PDF herunterladen',
'files.count': '{count} Dateien', 'files.count': '{count} Dateien',
'files.countSingular': '1 Datei', 'files.countSingular': '1 Datei',
@@ -1251,7 +1246,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Fehler beim Löschen der Datei', 'files.toast.deleteError': 'Fehler beim Löschen der Datei',
'files.sourcePlan': 'Tagesplan', 'files.sourcePlan': 'Tagesplan',
'files.sourceBooking': 'Buchung', 'files.sourceBooking': 'Buchung',
'files.sourceTransport': 'Transport',
'files.attach': 'Anhängen', 'files.attach': 'Anhängen',
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)', 'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
'files.trash': 'Papierkorb', 'files.trash': 'Papierkorb',
@@ -1264,7 +1258,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Datei zuweisen', 'files.assignTitle': 'Datei zuweisen',
'files.assignPlace': 'Ort', 'files.assignPlace': 'Ort',
'files.assignBooking': 'Buchung', 'files.assignBooking': 'Buchung',
'files.assignTransport': 'Transport',
'files.unassigned': 'Nicht zugewiesen', 'files.unassigned': 'Nicht zugewiesen',
'files.unlink': 'Verknüpfung entfernen', 'files.unlink': 'Verknüpfung entfernen',
'files.toast.trashed': 'In den Papierkorb verschoben', 'files.toast.trashed': 'In den Papierkorb verschoben',
@@ -1629,7 +1622,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln', 'memories.immichAutoUpload': 'Journey-Fotos beim Upload auch zu Immich spiegeln',
'memories.providerUrlHintSynology': 'Füge den Fotos-App-Pfad in die URL ein, z.B. https://nas:5001/photo', '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.testShort': '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',
@@ -1953,8 +1945,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert', 'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
'notif.trip_reminder.title': 'Reiseerinnerung', 'notif.trip_reminder.title': 'Reiseerinnerung',
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!', 'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
'notif.todo_due.title': 'Aufgabe fällig',
'notif.todo_due.text': '{todo} in {trip} ist am {due} fällig',
'notif.vacay_invite.title': 'Vacay Fusion-Einladung', 'notif.vacay_invite.title': 'Vacay Fusion-Einladung',
'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen', 'notif.vacay_invite.text': '{actor} hat dich zum Fusionieren von Urlaubsplänen eingeladen',
'notif.photos_shared.title': 'Fotos geteilt', 'notif.photos_shared.title': 'Fotos geteilt',
@@ -2356,12 +2346,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// System notices — persönlicher Dank // System notices — persönlicher Dank
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir', 'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.', 'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Aktion erforderlich: Benutzerkontokonflikt', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'Das 3.0.14-Upgrade hat einen oder mehrere Konflikte bei Benutzernamen oder E-Mail-Adressen festgestellt, die durch führende oder nachgestellte Leerzeichen in gespeicherten Konten verursacht wurden. Betroffene Konten wurden automatisch umbenannt. Prüfe die Serverprotokolle auf Zeilen, die mit **[migration] WHITESPACE COLLISION** beginnen, um die betroffenen Konten zu identifizieren.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Transport hinzufügen',
'transport.modalTitle.create': 'Transport hinzufügen',
'transport.modalTitle.edit': 'Transport bearbeiten',
'transport.title': 'Transporte', 'transport.title': 'Transporte',
'transport.addManual': 'Manuelles Transportmittel', 'transport.addManual': 'Manuelles Transportmittel',
} }
-28
View File
@@ -30,8 +30,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'None', 'common.none': 'None',
'common.date': 'Date', 'common.date': 'Date',
'common.rename': 'Rename', 'common.rename': 'Rename',
'common.discardChanges': 'Discard Changes',
'common.discard': 'Discard',
'common.name': 'Name', 'common.name': 'Name',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
@@ -124,20 +122,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'dashboard.toast.copied': 'Trip copied!', 'dashboard.toast.copied': 'Trip copied!',
'dashboard.toast.copyError': 'Failed to copy trip', 'dashboard.toast.copyError': 'Failed to copy trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.', 'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.confirm.copy.title': 'Copy this trip?',
'dashboard.confirm.copy.willCopy': 'Will be copied',
'dashboard.confirm.copy.will1': 'Days, places & day assignments',
'dashboard.confirm.copy.will2': 'Accommodations & reservations',
'dashboard.confirm.copy.will3': 'Budget items & category order',
'dashboard.confirm.copy.will4': 'Packing lists (unchecked)',
'dashboard.confirm.copy.will5': 'TODOs (unassigned & unchecked)',
'dashboard.confirm.copy.will6': 'Day notes',
'dashboard.confirm.copy.wontCopy': "Won't be copied",
'dashboard.confirm.copy.wont1': 'Collaborators & member assignments',
'dashboard.confirm.copy.wont2': 'Collab notes, polls & messages',
'dashboard.confirm.copy.wont3': 'Files & photos',
'dashboard.confirm.copy.wont4': 'Share tokens',
'dashboard.confirm.copy.confirm': 'Copy trip',
'dashboard.editTrip': 'Edit Trip', 'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip', 'dashboard.createTrip': 'Create New Trip',
'dashboard.tripTitle': 'Title', 'dashboard.tripTitle': 'Title',
@@ -220,7 +204,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Trip invitations', 'settings.notifyTripInvite': 'Trip invitations',
'settings.notifyBookingChange': 'Booking changes', 'settings.notifyBookingChange': 'Booking changes',
'settings.notifyTripReminder': 'Trip reminders', 'settings.notifyTripReminder': 'Trip reminders',
'settings.notifyTodoDue': 'Todo due soon',
'settings.notifyVacayInvite': 'Vacay fusion invitations', 'settings.notifyVacayInvite': 'Vacay fusion invitations',
'settings.notifyPhotosShared': 'Shared photos (Immich)', 'settings.notifyPhotosShared': 'Shared photos (Immich)',
'settings.notifyCollabMessage': 'Chat messages (Collab)', 'settings.notifyCollabMessage': 'Chat messages (Collab)',
@@ -1297,8 +1280,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Files', 'files.title': 'Files',
'files.pageTitle': 'Files & Documents', 'files.pageTitle': 'Files & Documents',
'files.subtitle': '{count} files for {trip}', 'files.subtitle': '{count} files for {trip}',
'files.download': 'Download',
'files.openError': 'Could not open file',
'files.downloadPdf': 'Download PDF', 'files.downloadPdf': 'Download PDF',
'files.count': '{count} files', 'files.count': '{count} files',
'files.countSingular': '1 file', 'files.countSingular': '1 file',
@@ -1322,7 +1303,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Failed to delete file', 'files.toast.deleteError': 'Failed to delete file',
'files.sourcePlan': 'Day Plan', 'files.sourcePlan': 'Day Plan',
'files.sourceBooking': 'Booking', 'files.sourceBooking': 'Booking',
'files.sourceTransport': 'Transport',
'files.attach': 'Attach', 'files.attach': 'Attach',
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)', 'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
'files.trash': 'Trash', 'files.trash': 'Trash',
@@ -1335,7 +1315,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Assign File', 'files.assignTitle': 'Assign File',
'files.assignPlace': 'Place', 'files.assignPlace': 'Place',
'files.assignBooking': 'Booking', 'files.assignBooking': 'Booking',
'files.assignTransport': 'Transport',
'files.unassigned': 'Unassigned', 'files.unassigned': 'Unassigned',
'files.unlink': 'Remove link', 'files.unlink': 'Remove link',
'files.toast.trashed': 'Moved to trash', 'files.toast.trashed': 'Moved to trash',
@@ -1702,7 +1681,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload', 'memories.immichAutoUpload': 'Mirror journey photos to Immich on upload',
'memories.providerUrlHintSynology': 'Include the Photos app path in the URL, e.g. https://nas:5001/photo', '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.testShort': 'Test',
'memories.testFirst': 'Test connection first', 'memories.testFirst': 'Test connection first',
'memories.connected': 'Connected', 'memories.connected': 'Connected',
'memories.disconnected': 'Not connected', 'memories.disconnected': 'Not connected',
@@ -1970,8 +1948,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} updated a booking in {trip}', 'notif.booking_change.text': '{actor} updated a booking in {trip}',
'notif.trip_reminder.title': 'Trip Reminder', 'notif.trip_reminder.title': 'Trip Reminder',
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!', 'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
'notif.todo_due.title': 'To-do due',
'notif.todo_due.text': '{todo} in {trip} is due on {due}',
'notif.vacay_invite.title': 'Vacay Fusion Invite', 'notif.vacay_invite.title': 'Vacay Fusion Invite',
'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans', 'notif.vacay_invite.text': '{actor} invited you to fuse vacation plans',
'notif.photos_shared.title': 'Photos Shared', 'notif.photos_shared.title': 'Photos Shared',
@@ -2393,10 +2369,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'system_notice.v3_thankyou.title': 'A personal note from me', 'system_notice.v3_thankyou.title': 'A personal note from me',
'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.', 'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.',
// System notices — 3.0.14
'system_notice.v3014_whitespace_collision.title': 'Action required: user account conflict',
'system_notice.v3014_whitespace_collision.body': 'The 3.0.14 upgrade detected one or more username or email collisions caused by leading/trailing whitespace in stored accounts. Affected accounts were renamed automatically. Check the server logs for lines starting with **[migration] WHITESPACE COLLISION** to identify which accounts need review.',
// System notices — onboarding // System notices — onboarding
'system_notice.welcome_v1.title': 'Welcome to TREK', 'system_notice.welcome_v1.title': 'Welcome to TREK',
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.', 'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
+3 -16
View File
@@ -30,8 +30,6 @@ const es: Record<string, string> = {
'common.none': 'Ninguno', 'common.none': 'Ninguno',
'common.date': 'Fecha', 'common.date': 'Fecha',
'common.rename': 'Renombrar', 'common.rename': 'Renombrar',
'common.discardChanges': 'Descartar cambios',
'common.discard': 'Descartar',
'common.name': 'Nombre', 'common.name': 'Nombre',
'common.email': 'Correo', 'common.email': 'Correo',
'common.password': 'Contraseña', 'common.password': 'Contraseña',
@@ -202,7 +200,6 @@ const es: Record<string, string> = {
'settings.notifyTripInvite': 'Invitaciones de viaje', 'settings.notifyTripInvite': 'Invitaciones de viaje',
'settings.notifyBookingChange': 'Cambios en reservas', 'settings.notifyBookingChange': 'Cambios en reservas',
'settings.notifyTripReminder': 'Recordatorios de viaje', 'settings.notifyTripReminder': 'Recordatorios de viaje',
'settings.notifyTodoDue': 'Tarea próxima',
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay', 'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
'settings.notifyPhotosShared': 'Fotos compartidas (Immich)', 'settings.notifyPhotosShared': 'Fotos compartidas (Immich)',
'settings.notifyCollabMessage': 'Mensajes de chat (Collab)', 'settings.notifyCollabMessage': 'Mensajes de chat (Collab)',
@@ -1170,8 +1167,6 @@ const es: Record<string, string> = {
'files.title': 'Archivos', 'files.title': 'Archivos',
'files.pageTitle': 'Archivos y documentos', 'files.pageTitle': 'Archivos y documentos',
'files.subtitle': '{count} archivos para {trip}', 'files.subtitle': '{count} archivos para {trip}',
'files.download': 'Descargar',
'files.openError': 'No se pudo abrir el archivo',
'files.downloadPdf': 'Descargar PDF', 'files.downloadPdf': 'Descargar PDF',
'files.count': '{count} archivos', 'files.count': '{count} archivos',
'files.countSingular': '1 archivo', 'files.countSingular': '1 archivo',
@@ -1195,7 +1190,6 @@ const es: Record<string, string> = {
'files.toast.deleteError': 'No se pudo eliminar el archivo', 'files.toast.deleteError': 'No se pudo eliminar el archivo',
'files.sourcePlan': 'Plan diario', 'files.sourcePlan': 'Plan diario',
'files.sourceBooking': 'Reserva', 'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Adjuntar', 'files.attach': 'Adjuntar',
'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)', 'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
@@ -1565,7 +1559,6 @@ const es: Record<string, string> = {
'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas', 'memories.immichAutoUpload': 'Duplicar las fotos del journey en Immich al subirlas',
'memories.providerUrlHintSynology': 'Incluye la ruta de la aplicación Photos en la URL, p.ej. https://nas:5001/photo', '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.testShort': 'Probar',
'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',
@@ -1683,7 +1676,6 @@ const es: Record<string, string> = {
'files.assignTitle': 'Asignar archivo', 'files.assignTitle': 'Asignar archivo',
'files.assignPlace': 'Lugar', 'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva', 'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Sin asignar', 'files.unassigned': 'Sin asignar',
'files.unlink': 'Eliminar vínculo', 'files.unlink': 'Eliminar vínculo',
'files.noteLabel': 'Nota', 'files.noteLabel': 'Nota',
@@ -1953,8 +1945,6 @@ const es: Record<string, string> = {
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}', 'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
'notif.trip_reminder.title': 'Recordatorio de viaje', 'notif.trip_reminder.title': 'Recordatorio de viaje',
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!', 'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
'notif.todo_due.title': 'Tarea pendiente',
'notif.todo_due.text': '{todo} en {trip} vence el {due}',
'notif.vacay_invite.title': 'Invitación Vacay Fusion', 'notif.vacay_invite.title': 'Invitación Vacay Fusion',
'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones', 'notif.vacay_invite.text': '{actor} te invitó a fusionar planes de vacaciones',
'notif.photos_shared.title': 'Fotos compartidas', 'notif.photos_shared.title': 'Fotos compartidas',
@@ -2352,12 +2342,9 @@ const es: Record<string, string> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte', 'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.', 'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Acción requerida: conflicto de cuenta de usuario', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'La actualización 3.0.14 detectó uno o más conflictos de nombre de usuario o correo electrónico causados por espacios en blanco al inicio o al final de los valores almacenados. Las cuentas afectadas se renombraron automáticamente. Revisa los registros del servidor en busca de líneas que empiecen por **[migration] WHITESPACE COLLISION** para identificar qué cuentas necesitan revisión.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Añadir transporte',
'transport.modalTitle.create': 'Añadir transporte',
'transport.modalTitle.edit': 'Editar transporte',
'transport.title': 'Transportes', 'transport.title': 'Transportes',
'transport.addManual': 'Transporte manual', 'transport.addManual': 'Transporte manual',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const fr: Record<string, string> = {
'common.none': 'Aucun', 'common.none': 'Aucun',
'common.date': 'Date', 'common.date': 'Date',
'common.rename': 'Renommer', 'common.rename': 'Renommer',
'common.discardChanges': 'Ignorer les modifications',
'common.discard': 'Ignorer',
'common.name': 'Nom', 'common.name': 'Nom',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Mot de passe', 'common.password': 'Mot de passe',
@@ -201,7 +199,6 @@ const fr: Record<string, string> = {
'settings.notifyTripInvite': 'Invitations de voyage', 'settings.notifyTripInvite': 'Invitations de voyage',
'settings.notifyBookingChange': 'Modifications de réservation', 'settings.notifyBookingChange': 'Modifications de réservation',
'settings.notifyTripReminder': 'Rappels de voyage', 'settings.notifyTripReminder': 'Rappels de voyage',
'settings.notifyTodoDue': 'Tâche à échéance',
'settings.notifyVacayInvite': 'Invitations de fusion Vacay', 'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
'settings.notifyPhotosShared': 'Photos partagées (Immich)', 'settings.notifyPhotosShared': 'Photos partagées (Immich)',
'settings.notifyCollabMessage': 'Messages de chat (Collab)', 'settings.notifyCollabMessage': 'Messages de chat (Collab)',
@@ -1220,8 +1217,6 @@ const fr: Record<string, string> = {
'files.title': 'Fichiers', 'files.title': 'Fichiers',
'files.pageTitle': 'Fichiers et documents', 'files.pageTitle': 'Fichiers et documents',
'files.subtitle': '{count} fichiers pour {trip}', 'files.subtitle': '{count} fichiers pour {trip}',
'files.download': 'Télécharger',
'files.openError': "Impossible d'ouvrir le fichier",
'files.downloadPdf': 'Télécharger le PDF', 'files.downloadPdf': 'Télécharger le PDF',
'files.count': '{count} fichiers', 'files.count': '{count} fichiers',
'files.countSingular': '1 fichier', 'files.countSingular': '1 fichier',
@@ -1245,7 +1240,6 @@ const fr: Record<string, string> = {
'files.toast.deleteError': 'Impossible de supprimer le fichier', 'files.toast.deleteError': 'Impossible de supprimer le fichier',
'files.sourcePlan': 'Plan du jour', 'files.sourcePlan': 'Plan du jour',
'files.sourceBooking': 'Réservation', 'files.sourceBooking': 'Réservation',
'files.sourceTransport': 'Transport',
'files.attach': 'Joindre', 'files.attach': 'Joindre',
'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)', 'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)',
'files.trash': 'Corbeille', 'files.trash': 'Corbeille',
@@ -1258,7 +1252,6 @@ const fr: Record<string, string> = {
'files.assignTitle': 'Assigner le fichier', 'files.assignTitle': 'Assigner le fichier',
'files.assignPlace': 'Lieu', 'files.assignPlace': 'Lieu',
'files.assignBooking': 'Réservation', 'files.assignBooking': 'Réservation',
'files.assignTransport': 'Transport',
'files.unassigned': 'Non attribué', 'files.unassigned': 'Non attribué',
'files.unlink': 'Supprimer le lien', 'files.unlink': 'Supprimer le lien',
'files.toast.trashed': 'Déplacé dans la corbeille', 'files.toast.trashed': 'Déplacé dans la corbeille',
@@ -1623,7 +1616,6 @@ const fr: Record<string, string> = {
'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement', 'memories.immichAutoUpload': 'Répliquer les photos du journey vers Immich au téléversement',
'memories.providerUrlHintSynology': 'Incluez le chemin de l\'application Photos dans l\'URL, ex. https://nas:5001/photo', '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.testShort': 'Tester',
'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é',
@@ -1947,8 +1939,6 @@ const fr: Record<string, string> = {
'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}', 'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}',
'notif.trip_reminder.title': 'Rappel de voyage', 'notif.trip_reminder.title': 'Rappel de voyage',
'notif.trip_reminder.text': 'Votre voyage {trip} approche !', 'notif.trip_reminder.text': 'Votre voyage {trip} approche !',
'notif.todo_due.title': 'Tâche à échéance',
'notif.todo_due.text': '{todo} dans {trip} est due le {due}',
'notif.vacay_invite.title': 'Invitation Vacay Fusion', 'notif.vacay_invite.title': 'Invitation Vacay Fusion',
'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances', 'notif.vacay_invite.text': '{actor} vous invite à fusionner les plans de vacances',
'notif.photos_shared.title': 'Photos partagées', 'notif.photos_shared.title': 'Photos partagées',
@@ -2346,12 +2336,9 @@ const fr: Record<string, string> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part', 'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.', 'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': "Action requise : conflit de compte utilisateur", 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': "La mise à niveau 3.0.14 a détecté un ou plusieurs conflits de nom d'utilisateur ou d'adresse e-mail causés par des espaces en début ou en fin de valeur dans les comptes enregistrés. Les comptes concernés ont été renommés automatiquement. Consultez les journaux du serveur pour les lignes commençant par **[migration] WHITESPACE COLLISION** afin d'identifier les comptes nécessitant une vérification.", 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Ajouter un transport',
'transport.modalTitle.create': 'Ajouter un transport',
'transport.modalTitle.edit': 'Modifier le transport',
'transport.title': 'Transports', 'transport.title': 'Transports',
'transport.addManual': 'Transport manuel', 'transport.addManual': 'Transport manuel',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nincs', 'common.none': 'Nincs',
'common.date': 'Dátum', 'common.date': 'Dátum',
'common.rename': 'Átnevezés', 'common.rename': 'Átnevezés',
'common.discardChanges': 'Változtatások elvetése',
'common.discard': 'Elveti',
'common.name': 'Név', 'common.name': 'Név',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Jelszó', 'common.password': 'Jelszó',
@@ -201,7 +199,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Utazási meghívók', 'settings.notifyTripInvite': 'Utazási meghívók',
'settings.notifyBookingChange': 'Foglalási változások', 'settings.notifyBookingChange': 'Foglalási változások',
'settings.notifyTripReminder': 'Utazási emlékeztetők', 'settings.notifyTripReminder': 'Utazási emlékeztetők',
'settings.notifyTodoDue': 'Teendő esedékes',
'settings.notifyVacayInvite': 'Vacay összevonási meghívók', 'settings.notifyVacayInvite': 'Vacay összevonási meghívók',
'settings.notifyPhotosShared': 'Megosztott fotók (Immich)', 'settings.notifyPhotosShared': 'Megosztott fotók (Immich)',
'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)', 'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)',
@@ -1221,8 +1218,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Fájlok', 'files.title': 'Fájlok',
'files.pageTitle': 'Fájlok és dokumentumok', 'files.pageTitle': 'Fájlok és dokumentumok',
'files.subtitle': '{count} fájl a következőhöz: {trip}', 'files.subtitle': '{count} fájl a következőhöz: {trip}',
'files.download': 'Letöltés',
'files.openError': 'A fájl megnyitása sikertelen',
'files.downloadPdf': 'PDF letöltése', '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',
@@ -1246,7 +1241,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nem sikerült törölni a fájlt', 'files.toast.deleteError': 'Nem sikerült törölni a fájlt',
'files.sourcePlan': 'Napi terv', 'files.sourcePlan': 'Napi terv',
'files.sourceBooking': 'Foglalás', 'files.sourceBooking': 'Foglalás',
'files.sourceTransport': 'Közlekedés',
'files.attach': 'Csatolás', 'files.attach': 'Csatolás',
'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)', 'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)',
'files.trash': 'Kuka', 'files.trash': 'Kuka',
@@ -1259,7 +1253,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Fájl hozzárendelése', 'files.assignTitle': 'Fájl hozzárendelése',
'files.assignPlace': 'Hely', 'files.assignPlace': 'Hely',
'files.assignBooking': 'Foglalás', 'files.assignBooking': 'Foglalás',
'files.assignTransport': 'Közlekedés',
'files.unassigned': 'Nincs hozzárendelve', 'files.unassigned': 'Nincs hozzárendelve',
'files.unlink': 'Kapcsolat eltávolítása', 'files.unlink': 'Kapcsolat eltávolítása',
'files.toast.trashed': 'Kukába helyezve', 'files.toast.trashed': 'Kukába helyezve',
@@ -1694,7 +1687,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is', 'memories.immichAutoUpload': 'Journey-fotók feltöltésekor másolat Immich-be is',
'memories.providerUrlHintSynology': 'Adja meg a Photos alkalmazás elérési útját az URL-ben, pl. https://nas:5001/photo', '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.testShort': 'Teszt',
'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',
@@ -1945,8 +1937,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban', 'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban',
'notif.trip_reminder.title': 'Utazás emlékeztető', 'notif.trip_reminder.title': 'Utazás emlékeztető',
'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!', 'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!',
'notif.todo_due.title': 'Teendő esedékes',
'notif.todo_due.text': '{todo} ({trip}) határideje: {due}',
'notif.vacay_invite.title': 'Vacay Fusion meghívó', 'notif.vacay_invite.title': 'Vacay Fusion meghívó',
'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához', 'notif.vacay_invite.text': '{actor} meghívott a nyaralási tervek összevonásához',
'notif.photos_shared.title': 'Fotók megosztva', 'notif.photos_shared.title': 'Fotók megosztva',
@@ -2347,12 +2337,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem', 'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.', 'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Szükséges beavatkozás: felhasználói fiókütközés', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'A 3.0.14-es frissítés egy vagy több felhasználónév- vagy e-mail-ütközést észlelt, amelyeket a tárolt értékek elején vagy végén lévő szóközök okoztak. Az érintett fiókok automatikusan át lettek nevezve. Ellenőrizze a szervernaplókat a **[migration] WHITESPACE COLLISION** kezdetű soroknál a felülvizsgálatot igénylő fiókok azonosításához.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Közlekedés hozzáadása',
'transport.modalTitle.create': 'Közlekedés hozzáadása',
'transport.modalTitle.edit': 'Közlekedés szerkesztése',
'transport.title': 'Közlekedés', 'transport.title': 'Közlekedés',
'transport.addManual': 'Kézi közlekedés', 'transport.addManual': 'Kézi közlekedés',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Tidak ada', 'common.none': 'Tidak ada',
'common.date': 'Tanggal', 'common.date': 'Tanggal',
'common.rename': 'Ganti nama', 'common.rename': 'Ganti nama',
'common.discardChanges': 'Buang perubahan',
'common.discard': 'Buang',
'common.name': 'Nama', 'common.name': 'Nama',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Kata sandi', 'common.password': 'Kata sandi',
@@ -204,7 +202,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Undangan perjalanan', 'settings.notifyTripInvite': 'Undangan perjalanan',
'settings.notifyBookingChange': 'Perubahan pemesanan', 'settings.notifyBookingChange': 'Perubahan pemesanan',
'settings.notifyTripReminder': 'Pengingat perjalanan', 'settings.notifyTripReminder': 'Pengingat perjalanan',
'settings.notifyTodoDue': 'Tugas jatuh tempo',
'settings.notifyVacayInvite': 'Undangan Vacay fusion', 'settings.notifyVacayInvite': 'Undangan Vacay fusion',
'settings.notifyPhotosShared': 'Foto dibagikan (Immich)', 'settings.notifyPhotosShared': 'Foto dibagikan (Immich)',
'settings.notifyCollabMessage': 'Pesan chat (Collab)', 'settings.notifyCollabMessage': 'Pesan chat (Collab)',
@@ -1281,8 +1278,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'File', 'files.title': 'File',
'files.pageTitle': 'File & Dokumen', 'files.pageTitle': 'File & Dokumen',
'files.subtitle': '{count} file untuk {trip}', 'files.subtitle': '{count} file untuk {trip}',
'files.download': 'Unduh',
'files.openError': 'Tidak dapat membuka file',
'files.downloadPdf': 'Unduh PDF', 'files.downloadPdf': 'Unduh PDF',
'files.count': '{count} file', 'files.count': '{count} file',
'files.countSingular': '1 berkas', 'files.countSingular': '1 berkas',
@@ -1306,7 +1301,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Gagal menghapus file', 'files.toast.deleteError': 'Gagal menghapus file',
'files.sourcePlan': 'Rencana Harian', 'files.sourcePlan': 'Rencana Harian',
'files.sourceBooking': 'Pemesanan', 'files.sourceBooking': 'Pemesanan',
'files.sourceTransport': 'Transportasi',
'files.attach': 'Lampirkan', 'files.attach': 'Lampirkan',
'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)', 'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)',
'files.trash': 'Sampah', 'files.trash': 'Sampah',
@@ -1319,7 +1313,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Tugaskan File', 'files.assignTitle': 'Tugaskan File',
'files.assignPlace': 'Tempat', 'files.assignPlace': 'Tempat',
'files.assignBooking': 'Pemesanan', 'files.assignBooking': 'Pemesanan',
'files.assignTransport': 'Transportasi',
'files.unassigned': 'Tidak ditugaskan', 'files.unassigned': 'Tidak ditugaskan',
'files.unlink': 'Hapus tautan', 'files.unlink': 'Hapus tautan',
'files.toast.trashed': 'Dipindahkan ke sampah', 'files.toast.trashed': 'Dipindahkan ke sampah',
@@ -1686,7 +1679,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah', 'memories.immichAutoUpload': 'Salin foto journey ke Immich saat diunggah',
'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Sertakan path aplikasi Photos di URL, mis. https://nas:5001/photo',
'memories.testConnection': 'Uji koneksi', 'memories.testConnection': 'Uji koneksi',
'memories.testShort': 'Uji',
'memories.testFirst': 'Uji koneksi terlebih dahulu', 'memories.testFirst': 'Uji koneksi terlebih dahulu',
'memories.connected': 'Terhubung', 'memories.connected': 'Terhubung',
'memories.disconnected': 'Tidak terhubung', 'memories.disconnected': 'Tidak terhubung',
@@ -1954,8 +1946,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} memperbarui pemesanan di {trip}', 'notif.booking_change.text': '{actor} memperbarui pemesanan di {trip}',
'notif.trip_reminder.title': 'Pengingat Perjalanan', 'notif.trip_reminder.title': 'Pengingat Perjalanan',
'notif.trip_reminder.text': 'Perjalananmu {trip} akan segera dimulai!', 'notif.trip_reminder.text': 'Perjalananmu {trip} akan segera dimulai!',
'notif.todo_due.title': 'Tugas jatuh tempo',
'notif.todo_due.text': '{todo} di {trip} jatuh tempo pada {due}',
'notif.vacay_invite.title': 'Undangan Vacay Fusion', 'notif.vacay_invite.title': 'Undangan Vacay Fusion',
'notif.vacay_invite.text': '{actor} mengundangmu untuk menggabungkan rencana liburan', 'notif.vacay_invite.text': '{actor} mengundangmu untuk menggabungkan rencana liburan',
'notif.photos_shared.title': 'Foto Dibagikan', 'notif.photos_shared.title': 'Foto Dibagikan',
@@ -2388,12 +2378,9 @@ const id: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya', 'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.', 'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Tindakan diperlukan: konflik akun pengguna', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'Pembaruan 3.0.14 mendeteksi satu atau lebih konflik nama pengguna atau email yang disebabkan oleh spasi di awal atau akhir nilai yang tersimpan. Akun yang terpengaruh telah diganti nama secara otomatis. Periksa log server untuk baris yang dimulai dengan **[migration] WHITESPACE COLLISION** guna mengidentifikasi akun mana yang perlu ditinjau.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Tambah transportasi',
'transport.modalTitle.create': 'Tambah transportasi',
'transport.modalTitle.edit': 'Edit transportasi',
'transport.title': 'Transportasi', 'transport.title': 'Transportasi',
'transport.addManual': 'Transportasi Manual', 'transport.addManual': 'Transportasi Manual',
}; };
+3 -16
View File
@@ -30,8 +30,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Nessuno', 'common.none': 'Nessuno',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Rinomina', 'common.rename': 'Rinomina',
'common.discardChanges': 'Scarta modifiche',
'common.discard': 'Scarta',
'common.name': 'Nome', 'common.name': 'Nome',
'common.email': 'Email', 'common.email': 'Email',
'common.password': 'Password', 'common.password': 'Password',
@@ -201,7 +199,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Inviti di viaggio', 'settings.notifyTripInvite': 'Inviti di viaggio',
'settings.notifyBookingChange': 'Modifiche alle prenotazioni', 'settings.notifyBookingChange': 'Modifiche alle prenotazioni',
'settings.notifyTripReminder': 'Promemoria di viaggio', 'settings.notifyTripReminder': 'Promemoria di viaggio',
'settings.notifyTodoDue': 'Attività in scadenza',
'settings.notifyVacayInvite': 'Inviti fusione Vacay', 'settings.notifyVacayInvite': 'Inviti fusione Vacay',
'settings.notifyPhotosShared': 'Foto condivise (Immich)', 'settings.notifyPhotosShared': 'Foto condivise (Immich)',
'settings.notifyCollabMessage': 'Messaggi chat (Collab)', 'settings.notifyCollabMessage': 'Messaggi chat (Collab)',
@@ -1221,8 +1218,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'File', 'files.title': 'File',
'files.pageTitle': 'File e documenti', 'files.pageTitle': 'File e documenti',
'files.subtitle': '{count} file per {trip}', 'files.subtitle': '{count} file per {trip}',
'files.download': 'Scarica',
'files.openError': 'Impossibile aprire il file',
'files.downloadPdf': 'Scarica PDF', 'files.downloadPdf': 'Scarica PDF',
'files.count': '{count} file', 'files.count': '{count} file',
'files.countSingular': '1 documento', 'files.countSingular': '1 documento',
@@ -1246,7 +1241,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Impossibile eliminare il file', 'files.toast.deleteError': 'Impossibile eliminare il file',
'files.sourcePlan': 'Programma giornaliero', 'files.sourcePlan': 'Programma giornaliero',
'files.sourceBooking': 'Prenotazione', 'files.sourceBooking': 'Prenotazione',
'files.sourceTransport': 'Trasporto',
'files.attach': 'Allega', 'files.attach': 'Allega',
'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)', 'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)',
'files.trash': 'Cestino', 'files.trash': 'Cestino',
@@ -1259,7 +1253,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Assegna file', 'files.assignTitle': 'Assegna file',
'files.assignPlace': 'Luogo', 'files.assignPlace': 'Luogo',
'files.assignBooking': 'Prenotazione', 'files.assignBooking': 'Prenotazione',
'files.assignTransport': 'Trasporto',
'files.unassigned': 'Non assegnato', 'files.unassigned': 'Non assegnato',
'files.unlink': 'Rimuovi collegamento', 'files.unlink': 'Rimuovi collegamento',
'files.toast.trashed': 'Spostato nel cestino', 'files.toast.trashed': 'Spostato nel cestino',
@@ -1624,7 +1617,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento', 'memories.immichAutoUpload': 'Rispecchia le foto del journey su Immich al caricamento',
'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Includi il percorso dell\'app Foto nell\'URL, es. https://nas:5001/photo',
'memories.testConnection': 'Test connessione', 'memories.testConnection': 'Test connessione',
'memories.testShort': 'Prova',
'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',
@@ -1948,8 +1940,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}', 'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}',
'notif.trip_reminder.title': 'Promemoria viaggio', 'notif.trip_reminder.title': 'Promemoria viaggio',
'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!', 'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!',
'notif.todo_due.title': 'Attività in scadenza',
'notif.todo_due.text': '{todo} in {trip} scade il {due}',
'notif.vacay_invite.title': 'Invito Vacay Fusion', 'notif.vacay_invite.title': 'Invito Vacay Fusion',
'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza', 'notif.vacay_invite.text': '{actor} ti ha invitato a fondere i piani vacanza',
'notif.photos_shared.title': 'Foto condivise', 'notif.photos_shared.title': 'Foto condivise',
@@ -2347,12 +2337,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia', 'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.', 'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Azione richiesta: conflitto di account utente', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': "L'aggiornamento 3.0.14 ha rilevato uno o più conflitti di nome utente o e-mail causati da spazi iniziali o finali nei valori memorizzati. Gli account interessati sono stati rinominati automaticamente. Controlla i log del server per le righe che iniziano con **[migration] WHITESPACE COLLISION** per identificare quali account richiedono revisione.", 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Aggiungi trasporto',
'transport.modalTitle.create': 'Aggiungi trasporto',
'transport.modalTitle.edit': 'Modifica trasporto',
'transport.title': 'Trasporti', 'transport.title': 'Trasporti',
'transport.addManual': 'Trasporto manuale', 'transport.addManual': 'Trasporto manuale',
} }
+7 -20
View File
@@ -30,8 +30,6 @@ const nl: Record<string, string> = {
'common.none': 'Geen', 'common.none': 'Geen',
'common.date': 'Datum', 'common.date': 'Datum',
'common.rename': 'Hernoemen', 'common.rename': 'Hernoemen',
'common.discardChanges': 'Wijzigingen verwerpen',
'common.discard': 'Verwerpen',
'common.name': 'Naam', 'common.name': 'Naam',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Wachtwoord', 'common.password': 'Wachtwoord',
@@ -201,7 +199,6 @@ const nl: Record<string, string> = {
'settings.notifyTripInvite': 'Reisuitnodigingen', 'settings.notifyTripInvite': 'Reisuitnodigingen',
'settings.notifyBookingChange': 'Boekingswijzigingen', 'settings.notifyBookingChange': 'Boekingswijzigingen',
'settings.notifyTripReminder': 'Reisherinneringen', 'settings.notifyTripReminder': 'Reisherinneringen',
'settings.notifyTodoDue': 'Taak verloopt',
'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen', 'settings.notifyVacayInvite': 'Vacay-fusieuitnodigingen',
'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)', 'settings.notifyPhotosShared': 'Gedeelde foto\'s (Immich)',
'settings.notifyCollabMessage': 'Chatberichten (Collab)', 'settings.notifyCollabMessage': 'Chatberichten (Collab)',
@@ -614,8 +611,8 @@ const nl: Record<string, string> = {
'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking', 'admin.collab.chat.subtitle': 'Realtime berichten voor reissamenwerking',
'admin.collab.notes.title': 'Notities', 'admin.collab.notes.title': 'Notities',
'admin.collab.notes.subtitle': 'Gedeelde notities en documenten', 'admin.collab.notes.subtitle': 'Gedeelde notities en documenten',
'admin.collab.polls.title': 'Polls', 'admin.collab.polls.title': 'Peilingen',
'admin.collab.polls.subtitle': 'Groepspolls en stemmen', 'admin.collab.polls.subtitle': 'Groepspeilingen en stemmen',
'admin.collab.whatsnext.title': 'Wat nu', 'admin.collab.whatsnext.title': 'Wat nu',
'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen', 'admin.collab.whatsnext.subtitle': 'Activiteitssuggesties en volgende stappen',
'admin.tabs.config': 'Personalisatie', 'admin.tabs.config': 'Personalisatie',
@@ -1220,8 +1217,6 @@ const nl: Record<string, string> = {
'files.title': 'Bestanden', 'files.title': 'Bestanden',
'files.pageTitle': 'Bestanden en documenten', 'files.pageTitle': 'Bestanden en documenten',
'files.subtitle': '{count} bestanden voor {trip}', 'files.subtitle': '{count} bestanden voor {trip}',
'files.download': 'Downloaden',
'files.openError': 'Bestand kon niet worden geopend',
'files.downloadPdf': 'PDF downloaden', 'files.downloadPdf': 'PDF downloaden',
'files.count': '{count} bestanden', 'files.count': '{count} bestanden',
'files.countSingular': '1 bestand', 'files.countSingular': '1 bestand',
@@ -1245,7 +1240,6 @@ const nl: Record<string, string> = {
'files.toast.deleteError': 'Bestand verwijderen mislukt', 'files.toast.deleteError': 'Bestand verwijderen mislukt',
'files.sourcePlan': 'Dagplan', 'files.sourcePlan': 'Dagplan',
'files.sourceBooking': 'Boeking', 'files.sourceBooking': 'Boeking',
'files.sourceTransport': 'Transport',
'files.attach': 'Bijvoegen', 'files.attach': 'Bijvoegen',
'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)', 'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)',
'files.trash': 'Prullenbak', 'files.trash': 'Prullenbak',
@@ -1258,7 +1252,6 @@ const nl: Record<string, string> = {
'files.assignTitle': 'Bestand toewijzen', 'files.assignTitle': 'Bestand toewijzen',
'files.assignPlace': 'Plaats', 'files.assignPlace': 'Plaats',
'files.assignBooking': 'Boeking', 'files.assignBooking': 'Boeking',
'files.assignTransport': 'Transport',
'files.unassigned': 'Niet toegewezen', 'files.unassigned': 'Niet toegewezen',
'files.unlink': 'Koppeling verwijderen', 'files.unlink': 'Koppeling verwijderen',
'files.toast.trashed': 'Naar prullenbak verplaatst', 'files.toast.trashed': 'Naar prullenbak verplaatst',
@@ -1623,7 +1616,6 @@ const nl: Record<string, string> = {
'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen', 'memories.immichAutoUpload': 'Journey-foto\'s bij upload ook naar Immich spiegelen',
'memories.providerUrlHintSynology': 'Voeg het pad van de Photos-app toe aan de URL, bijv. https://nas:5001/photo', '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.testShort': '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',
@@ -1663,7 +1655,7 @@ const nl: Record<string, string> = {
// Collab Addon // Collab Addon
'collab.tabs.chat': 'Chat', 'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities', 'collab.tabs.notes': 'Notities',
'collab.tabs.polls': 'Polls', 'collab.tabs.polls': 'Peilingen',
'collab.whatsNext.title': 'Wat komt er', 'collab.whatsNext.title': 'Wat komt er',
'collab.whatsNext.today': 'Vandaag', 'collab.whatsNext.today': 'Vandaag',
'collab.whatsNext.tomorrow': 'Morgen', 'collab.whatsNext.tomorrow': 'Morgen',
@@ -1709,7 +1701,7 @@ const nl: Record<string, string> = {
'collab.notes.attachFiles': 'Bestanden bijvoegen', 'collab.notes.attachFiles': 'Bestanden bijvoegen',
'collab.notes.noCategoriesYet': 'Nog geen categorieën', 'collab.notes.noCategoriesYet': 'Nog geen categorieën',
'collab.notes.emptyDesc': 'Maak een notitie om te beginnen', 'collab.notes.emptyDesc': 'Maak een notitie om te beginnen',
'collab.polls.title': 'Polls', 'collab.polls.title': 'Peilingen',
'collab.polls.new': 'Nieuwe poll', 'collab.polls.new': 'Nieuwe poll',
'collab.polls.empty': 'Nog geen polls', 'collab.polls.empty': 'Nog geen polls',
'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen', 'collab.polls.emptyHint': 'Stel de groep een vraag en stem samen',
@@ -1947,8 +1939,6 @@ const nl: Record<string, string> = {
'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}', 'notif.booking_change.text': '{actor} heeft een boeking bijgewerkt in {trip}',
'notif.trip_reminder.title': 'Reisherinnering', 'notif.trip_reminder.title': 'Reisherinnering',
'notif.trip_reminder.text': 'Je reis {trip} komt eraan!', 'notif.trip_reminder.text': 'Je reis {trip} komt eraan!',
'notif.todo_due.title': 'Taak verloopt',
'notif.todo_due.text': '{todo} in {trip} verloopt op {due}',
'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging', 'notif.vacay_invite.title': 'Vacay Fusion-uitnodiging',
'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren', 'notif.vacay_invite.text': '{actor} nodigt je uit om vakantieplannen te fuseren',
'notif.photos_shared.title': 'Foto\'s gedeeld', 'notif.photos_shared.title': 'Foto\'s gedeeld',
@@ -2346,12 +2336,9 @@ const nl: Record<string, string> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij', 'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.', 'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Actie vereist: gebruikersaccountconflict', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'De 3.0.14-upgrade heeft één of meer conflicten in gebruikersnaam of e-mailadres gedetecteerd, veroorzaakt door spaties aan het begin of einde van opgeslagen waarden. Getroffen accounts zijn automatisch hernoemd. Controleer de serverlogboeken op regels die beginnen met **[migration] WHITESPACE COLLISION** om te achterhalen welke accounts moeten worden beoordeeld.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Vervoer toevoegen',
'transport.modalTitle.create': 'Vervoer toevoegen',
'transport.modalTitle.edit': 'Vervoer bewerken',
'transport.title': 'Transport', 'transport.title': 'Transport',
'transport.addManual': 'Handmatig transport', 'transport.addManual': 'Handmatig transport',
} }
+3 -16
View File
@@ -26,8 +26,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'common.none': 'Brak', 'common.none': 'Brak',
'common.date': 'Data', 'common.date': 'Data',
'common.rename': 'Zmień nazwę', 'common.rename': 'Zmień nazwę',
'common.discardChanges': 'Odrzuć zmiany',
'common.discard': 'Odrzuć',
'common.name': 'Nazwa', 'common.name': 'Nazwa',
'common.email': 'E-mail', 'common.email': 'E-mail',
'common.password': 'Hasło', 'common.password': 'Hasło',
@@ -184,7 +182,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.notifyTripInvite': 'Zaproszenia do podróży', 'settings.notifyTripInvite': 'Zaproszenia do podróży',
'settings.notifyBookingChange': 'Zmiany w rezerwacjach', 'settings.notifyBookingChange': 'Zmiany w rezerwacjach',
'settings.notifyTripReminder': 'Przypomnienia o podróżach', 'settings.notifyTripReminder': 'Przypomnienia o podróżach',
'settings.notifyTodoDue': 'Zadanie z terminem',
'settings.notifyVacayInvite': 'Zaproszenia do połączenia kalendarzy', 'settings.notifyVacayInvite': 'Zaproszenia do połączenia kalendarzy',
'settings.notifyPhotosShared': 'Udostępnione zdjęcia (Immich)', 'settings.notifyPhotosShared': 'Udostępnione zdjęcia (Immich)',
'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)', 'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)',
@@ -1172,8 +1169,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.title': 'Pliki', 'files.title': 'Pliki',
'files.pageTitle': 'Pliki i dokumenty', 'files.pageTitle': 'Pliki i dokumenty',
'files.subtitle': '{count} plików dla {trip}', 'files.subtitle': '{count} plików dla {trip}',
'files.download': 'Pobierz',
'files.openError': 'Nie można otworzyć pliku',
'files.downloadPdf': 'Pobierz PDF', 'files.downloadPdf': 'Pobierz PDF',
'files.count': '{count} plików', 'files.count': '{count} plików',
'files.countSingular': '1 plik', 'files.countSingular': '1 plik',
@@ -1197,7 +1192,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nie udało się usunąć pliku', 'files.toast.deleteError': 'Nie udało się usunąć pliku',
'files.sourcePlan': 'Plan dni', 'files.sourcePlan': 'Plan dni',
'files.sourceBooking': 'Rezerwacje', 'files.sourceBooking': 'Rezerwacje',
'files.sourceTransport': 'Transport',
'files.attach': 'Załącz', 'files.attach': 'Załącz',
'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)', 'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)',
'files.trash': 'Kosz', 'files.trash': 'Kosz',
@@ -1210,7 +1204,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Przypisz plik', 'files.assignTitle': 'Przypisz plik',
'files.assignPlace': 'Miejsce', 'files.assignPlace': 'Miejsce',
'files.assignBooking': 'Rezerwacja', 'files.assignBooking': 'Rezerwacja',
'files.assignTransport': 'Transport',
'files.unassigned': 'Nieprzypisane', 'files.unassigned': 'Nieprzypisane',
'files.unlink': 'Usuń link', 'files.unlink': 'Usuń link',
'files.toast.trashed': 'Przeniesiono do kosza', 'files.toast.trashed': 'Przeniesiono do kosza',
@@ -1575,7 +1568,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich', 'memories.immichAutoUpload': 'Przy przesyłaniu kopiuj zdjęcia journey także do Immich',
'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Uwzględnij ścieżkę aplikacji Photos w URL, np. https://nas:5001/photo',
'memories.testConnection': 'Test', 'memories.testConnection': 'Test',
'memories.testShort': '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',
@@ -1937,8 +1929,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}', 'notif.booking_change.text': '{actor} zaktualizował rezerwację w {trip}',
'notif.trip_reminder.title': 'Przypomnienie o podróży', 'notif.trip_reminder.title': 'Przypomnienie o podróży',
'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!', 'notif.trip_reminder.text': 'Twoja podróż {trip} zbliża się!',
'notif.todo_due.title': 'Zadanie z terminem',
'notif.todo_due.text': '{todo} w {trip} — termin {due}',
'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion', 'notif.vacay_invite.title': 'Zaproszenie Vacay Fusion',
'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych', 'notif.vacay_invite.text': '{actor} zaprosił Cię do połączenia planów urlopowych',
'notif.photos_shared.title': 'Zdjęcia udostępnione', 'notif.photos_shared.title': 'Zdjęcia udostępnione',
@@ -2339,12 +2329,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie', 'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.', 'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Wymagane działanie: konflikt konta użytkownika', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'Aktualizacja 3.0.14 wykryła jeden lub więcej konfliktów nazwy użytkownika lub adresu e-mail spowodowanych spacjami na początku lub końcu przechowywanych wartości. Dotknięte konta zostały automatycznie przemianowane. Sprawdź logi serwera pod kątem wierszy zaczynających się od **[migration] WHITESPACE COLLISION**, aby zidentyfikować konta wymagające przeglądu.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Dodaj transport',
'transport.modalTitle.create': 'Dodaj transport',
'transport.modalTitle.edit': 'Edytuj transport',
'transport.title': 'Transport', 'transport.title': 'Transport',
'transport.addManual': 'Ręczny transport', 'transport.addManual': 'Ręczny transport',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const ru: Record<string, string> = {
'common.none': 'Нет', 'common.none': 'Нет',
'common.date': 'Дата', 'common.date': 'Дата',
'common.rename': 'Переименовать', 'common.rename': 'Переименовать',
'common.discardChanges': 'Отменить изменения',
'common.discard': 'Отменить',
'common.name': 'Имя', 'common.name': 'Имя',
'common.email': 'Эл. почта', 'common.email': 'Эл. почта',
'common.password': 'Пароль', 'common.password': 'Пароль',
@@ -201,7 +199,6 @@ const ru: Record<string, string> = {
'settings.notifyTripInvite': 'Приглашения в поездку', 'settings.notifyTripInvite': 'Приглашения в поездку',
'settings.notifyBookingChange': 'Изменения бронирований', 'settings.notifyBookingChange': 'Изменения бронирований',
'settings.notifyTripReminder': 'Напоминания о поездке', 'settings.notifyTripReminder': 'Напоминания о поездке',
'settings.notifyTodoDue': 'Задача к сроку',
'settings.notifyVacayInvite': 'Приглашения слияния Vacay', 'settings.notifyVacayInvite': 'Приглашения слияния Vacay',
'settings.notifyPhotosShared': 'Общие фото (Immich)', 'settings.notifyPhotosShared': 'Общие фото (Immich)',
'settings.notifyCollabMessage': 'Сообщения чата (Collab)', 'settings.notifyCollabMessage': 'Сообщения чата (Collab)',
@@ -1220,8 +1217,6 @@ const ru: Record<string, string> = {
'files.title': 'Файлы', 'files.title': 'Файлы',
'files.pageTitle': 'Файлы и документы', 'files.pageTitle': 'Файлы и документы',
'files.subtitle': '{count} файлов для {trip}', 'files.subtitle': '{count} файлов для {trip}',
'files.download': 'Скачать',
'files.openError': 'Не удалось открыть файл',
'files.downloadPdf': 'Скачать PDF', 'files.downloadPdf': 'Скачать PDF',
'files.count': '{count} файлов', 'files.count': '{count} файлов',
'files.countSingular': '1 файл', 'files.countSingular': '1 файл',
@@ -1245,7 +1240,6 @@ const ru: Record<string, string> = {
'files.toast.deleteError': 'Не удалось удалить файл', 'files.toast.deleteError': 'Не удалось удалить файл',
'files.sourcePlan': 'План дня', 'files.sourcePlan': 'План дня',
'files.sourceBooking': 'Бронирование', 'files.sourceBooking': 'Бронирование',
'files.sourceTransport': 'Транспорт',
'files.attach': 'Прикрепить', 'files.attach': 'Прикрепить',
'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)', 'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)',
'files.trash': 'Корзина', 'files.trash': 'Корзина',
@@ -1258,7 +1252,6 @@ const ru: Record<string, string> = {
'files.assignTitle': 'Назначить файл', 'files.assignTitle': 'Назначить файл',
'files.assignPlace': 'Место', 'files.assignPlace': 'Место',
'files.assignBooking': 'Бронирование', 'files.assignBooking': 'Бронирование',
'files.assignTransport': 'Транспорт',
'files.unassigned': 'Не назначен', 'files.unassigned': 'Не назначен',
'files.unlink': 'Удалить связь', 'files.unlink': 'Удалить связь',
'files.toast.trashed': 'Перемещено в корзину', 'files.toast.trashed': 'Перемещено в корзину',
@@ -1623,7 +1616,6 @@ const ru: Record<string, string> = {
'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке', 'memories.immichAutoUpload': 'Дублировать фото journey в Immich при загрузке',
'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo', 'memories.providerUrlHintSynology': 'Включите путь приложения Photos в URL, например https://nas:5001/photo',
'memories.testConnection': 'Проверить подключение', 'memories.testConnection': 'Проверить подключение',
'memories.testShort': 'Проверить',
'memories.testFirst': 'Сначала проверьте подключение', 'memories.testFirst': 'Сначала проверьте подключение',
'memories.connected': 'Подключено', 'memories.connected': 'Подключено',
'memories.disconnected': 'Не подключено', 'memories.disconnected': 'Не подключено',
@@ -1944,8 +1936,6 @@ const ru: Record<string, string> = {
'notif.booking_change.text': '{actor} обновил бронирование в {trip}', 'notif.booking_change.text': '{actor} обновил бронирование в {trip}',
'notif.trip_reminder.title': 'Напоминание о поездке', 'notif.trip_reminder.title': 'Напоминание о поездке',
'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!', 'notif.trip_reminder.text': 'Ваша поездка {trip} скоро начнётся!',
'notif.todo_due.title': 'Задача к сроку',
'notif.todo_due.text': '{todo} в {trip} — срок {due}',
'notif.vacay_invite.title': 'Приглашение Vacay Fusion', 'notif.vacay_invite.title': 'Приглашение Vacay Fusion',
'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска', 'notif.vacay_invite.text': '{actor} приглашает вас объединить планы отпуска',
'notif.photos_shared.title': 'Фото опубликованы', 'notif.photos_shared.title': 'Фото опубликованы',
@@ -2346,12 +2336,9 @@ const ru: Record<string, string> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': 'Личное слово от меня', 'system_notice.v3_thankyou.title': 'Личное слово от меня',
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.', 'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': 'Требуется действие: конфликт учётных записей', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': 'Обновление 3.0.14 обнаружило один или несколько конфликтов имён пользователей или адресов электронной почты, вызванных ведущими или завершающими пробелами в сохранённых значениях. Затронутые учётные записи были автоматически переименованы. Проверьте логи сервера на строки, начинающиеся с **[migration] WHITESPACE COLLISION**, чтобы определить учётные записи, требующие проверки.', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': 'Добавить транспорт',
'transport.modalTitle.create': 'Добавить транспорт',
'transport.modalTitle.edit': 'Изменить транспорт',
'transport.title': 'Транспорт', 'transport.title': 'Транспорт',
'transport.addManual': 'Ручной транспорт', 'transport.addManual': 'Ручной транспорт',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const zh: Record<string, string> = {
'common.none': '无', 'common.none': '无',
'common.date': '日期', 'common.date': '日期',
'common.rename': '重命名', 'common.rename': '重命名',
'common.discardChanges': '放弃更改',
'common.discard': '放弃',
'common.name': '名称', 'common.name': '名称',
'common.email': '邮箱', 'common.email': '邮箱',
'common.password': '密码', 'common.password': '密码',
@@ -201,7 +199,6 @@ const zh: Record<string, string> = {
'settings.notifyTripInvite': '旅行邀请', 'settings.notifyTripInvite': '旅行邀请',
'settings.notifyBookingChange': '预订变更', 'settings.notifyBookingChange': '预订变更',
'settings.notifyTripReminder': '旅行提醒', 'settings.notifyTripReminder': '旅行提醒',
'settings.notifyTodoDue': '待办事项即将到期',
'settings.notifyVacayInvite': 'Vacay 融合邀请', 'settings.notifyVacayInvite': 'Vacay 融合邀请',
'settings.notifyPhotosShared': '共享照片 (Immich)', 'settings.notifyPhotosShared': '共享照片 (Immich)',
'settings.notifyCollabMessage': '聊天消息 (Collab)', 'settings.notifyCollabMessage': '聊天消息 (Collab)',
@@ -1220,8 +1217,6 @@ const zh: Record<string, string> = {
'files.title': '文件', 'files.title': '文件',
'files.pageTitle': '文件与文档', 'files.pageTitle': '文件与文档',
'files.subtitle': '{trip} 的 {count} 个文件', 'files.subtitle': '{trip} 的 {count} 个文件',
'files.download': '下载',
'files.openError': '无法打开文件',
'files.downloadPdf': '下载 PDF', 'files.downloadPdf': '下载 PDF',
'files.count': '{count} 个文件', 'files.count': '{count} 个文件',
'files.countSingular': '1 个文件', 'files.countSingular': '1 个文件',
@@ -1245,7 +1240,6 @@ const zh: Record<string, string> = {
'files.toast.deleteError': '删除文件失败', 'files.toast.deleteError': '删除文件失败',
'files.sourcePlan': '日程计划', 'files.sourcePlan': '日程计划',
'files.sourceBooking': '预订', 'files.sourceBooking': '预订',
'files.sourceTransport': '交通',
'files.attach': '附加', 'files.attach': '附加',
'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)', 'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)',
'files.trash': '回收站', 'files.trash': '回收站',
@@ -1258,7 +1252,6 @@ const zh: Record<string, string> = {
'files.assignTitle': '分配文件', 'files.assignTitle': '分配文件',
'files.assignPlace': '地点', 'files.assignPlace': '地点',
'files.assignBooking': '预订', 'files.assignBooking': '预订',
'files.assignTransport': '交通',
'files.unassigned': '未分配', 'files.unassigned': '未分配',
'files.unlink': '移除关联', 'files.unlink': '移除关联',
'files.toast.trashed': '已移至回收站', 'files.toast.trashed': '已移至回收站',
@@ -1623,7 +1616,6 @@ const zh: Record<string, string> = {
'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich', 'memories.immichAutoUpload': '上传 Journey 照片时同步到 Immich',
'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo', 'memories.providerUrlHintSynology': '在 URL 中包含照片应用路径,例如 https://nas:5001/photo',
'memories.testConnection': '测试连接', 'memories.testConnection': '测试连接',
'memories.testShort': '测试',
'memories.testFirst': '请先测试连接', 'memories.testFirst': '请先测试连接',
'memories.connected': '已连接', 'memories.connected': '已连接',
'memories.disconnected': '未连接', 'memories.disconnected': '未连接',
@@ -1944,8 +1936,6 @@ const zh: Record<string, string> = {
'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订', 'notif.booking_change.text': '{actor} 更新了 {trip} 中的预订',
'notif.trip_reminder.title': '旅行提醒', 'notif.trip_reminder.title': '旅行提醒',
'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!', 'notif.trip_reminder.text': '您的旅行 {trip} 即将开始!',
'notif.todo_due.title': '待办事项即将到期',
'notif.todo_due.text': '{trip} 中的 {todo} 将于 {due} 到期',
'notif.vacay_invite.title': 'Vacay 融合邀请', 'notif.vacay_invite.title': 'Vacay 融合邀请',
'notif.vacay_invite.text': '{actor} 邀请您合并假期计划', 'notif.vacay_invite.text': '{actor} 邀请您合并假期计划',
'notif.photos_shared.title': '照片已分享', 'notif.photos_shared.title': '照片已分享',
@@ -2346,12 +2336,9 @@ const zh: Record<string, string> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': '来自我的一封私人信', 'system_notice.v3_thankyou.title': '来自我的一封私人信',
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。', 'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': '需要操作:用户账户冲突', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': '3.0.14 版本升级检测到一个或多个由存储账户中首尾空白字符引发的用户名或邮箱冲突。受影响的账户已自动重命名。请检查服务器日志中以 **[migration] WHITESPACE COLLISION** 开头的行,以确认哪些账户需要审查。', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': '添加交通',
'transport.modalTitle.create': '添加交通',
'transport.modalTitle.edit': '编辑交通',
'transport.title': '交通', 'transport.title': '交通',
'transport.addManual': '手动添加交通', 'transport.addManual': '手动添加交通',
} }
+3 -16
View File
@@ -30,8 +30,6 @@ const zhTw: Record<string, string> = {
'common.none': '無', 'common.none': '無',
'common.date': '日期', 'common.date': '日期',
'common.rename': '重新命名', 'common.rename': '重新命名',
'common.discardChanges': '捨棄變更',
'common.discard': '捨棄',
'common.name': '名稱', 'common.name': '名稱',
'common.email': '郵箱', 'common.email': '郵箱',
'common.password': '密碼', 'common.password': '密碼',
@@ -201,7 +199,6 @@ const zhTw: Record<string, string> = {
'settings.notifyTripInvite': '旅行邀請', 'settings.notifyTripInvite': '旅行邀請',
'settings.notifyBookingChange': '預訂變更', 'settings.notifyBookingChange': '預訂變更',
'settings.notifyTripReminder': '旅行提醒', 'settings.notifyTripReminder': '旅行提醒',
'settings.notifyTodoDue': '待辦事項即將到期',
'settings.notifyVacayInvite': 'Vacay 融合邀請', 'settings.notifyVacayInvite': 'Vacay 融合邀請',
'settings.notifyPhotosShared': '共享照片 (Immich)', 'settings.notifyPhotosShared': '共享照片 (Immich)',
'settings.notifyCollabMessage': '聊天訊息 (Collab)', 'settings.notifyCollabMessage': '聊天訊息 (Collab)',
@@ -1280,8 +1277,6 @@ const zhTw: Record<string, string> = {
'files.title': '檔案', 'files.title': '檔案',
'files.pageTitle': '檔案與文件', 'files.pageTitle': '檔案與文件',
'files.subtitle': '{trip} 的 {count} 個檔案', 'files.subtitle': '{trip} 的 {count} 個檔案',
'files.download': '下載',
'files.openError': '無法開啟檔案',
'files.downloadPdf': '下載 PDF', 'files.downloadPdf': '下載 PDF',
'files.count': '{count} 個檔案', 'files.count': '{count} 個檔案',
'files.countSingular': '1 個檔案', 'files.countSingular': '1 個檔案',
@@ -1305,7 +1300,6 @@ const zhTw: Record<string, string> = {
'files.toast.deleteError': '刪除檔案失敗', 'files.toast.deleteError': '刪除檔案失敗',
'files.sourcePlan': '日程計劃', 'files.sourcePlan': '日程計劃',
'files.sourceBooking': '預訂', 'files.sourceBooking': '預訂',
'files.sourceTransport': '交通',
'files.attach': '附加', 'files.attach': '附加',
'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)', 'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)',
'files.trash': '回收站', 'files.trash': '回收站',
@@ -1318,7 +1312,6 @@ const zhTw: Record<string, string> = {
'files.assignTitle': '分配檔案', 'files.assignTitle': '分配檔案',
'files.assignPlace': '地點', 'files.assignPlace': '地點',
'files.assignBooking': '預訂', 'files.assignBooking': '預訂',
'files.assignTransport': '交通',
'files.unassigned': '未分配', 'files.unassigned': '未分配',
'files.unlink': '移除關聯', 'files.unlink': '移除關聯',
'files.toast.trashed': '已移至回收站', 'files.toast.trashed': '已移至回收站',
@@ -1683,7 +1676,6 @@ const zhTw: Record<string, string> = {
'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich', 'memories.immichAutoUpload': '上傳 Journey 照片時同步到 Immich',
'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo', 'memories.providerUrlHintSynology': '在網址中包含照片應用程式路徑,例如 https://nas:5001/photo',
'memories.testConnection': '測試連線', 'memories.testConnection': '測試連線',
'memories.testShort': '測試',
'memories.testFirst': '請先測試連線', 'memories.testFirst': '請先測試連線',
'memories.connected': '已連線', 'memories.connected': '已連線',
'memories.disconnected': '未連線', 'memories.disconnected': '未連線',
@@ -2203,8 +2195,6 @@ const zhTw: Record<string, string> = {
'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂', 'notif.booking_change.text': '{actor} 更新了 {trip} 中的預訂',
'notif.trip_reminder.title': '旅行提醒', 'notif.trip_reminder.title': '旅行提醒',
'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!', 'notif.trip_reminder.text': '你的旅行 {trip} 即將開始!',
'notif.todo_due.title': '待辦事項即將到期',
'notif.todo_due.text': '{trip} 中的 {todo} 將於 {due} 到期',
'notif.vacay_invite.title': 'Vacay Fusion 邀請', 'notif.vacay_invite.title': 'Vacay Fusion 邀請',
'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫', 'notif.vacay_invite.text': '{actor} 邀請你合併假期計畫',
'notif.photos_shared.title': '照片已分享', 'notif.photos_shared.title': '照片已分享',
@@ -2347,12 +2337,9 @@ const zhTw: Record<string, string> = {
// System notices — personal thank you // System notices — personal thank you
'system_notice.v3_thankyou.title': '來自我的一封私人信', 'system_notice.v3_thankyou.title': '來自我的一封私人信',
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。', 'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
// System notices — 3.0.14 'transport.addTransport': 'Add transport',
'system_notice.v3014_whitespace_collision.title': '需要操作:使用者帳戶衝突', 'transport.modalTitle.create': 'Add transport',
'system_notice.v3014_whitespace_collision.body': '3.0.14 版本升級偵測到一個或多個由儲存帳戶中前後空白字元引發的使用者名稱或電子郵件衝突。受影響的帳戶已自動重新命名。請檢查伺服器日誌中以 **[migration] WHITESPACE COLLISION** 開頭的行,以確認哪些帳戶需要審查。', 'transport.modalTitle.edit': 'Edit transport',
'transport.addTransport': '新增交通',
'transport.modalTitle.create': '新增交通',
'transport.modalTitle.edit': '編輯交通',
'transport.title': '交通', 'transport.title': '交通',
'transport.addManual': '手動新增交通', 'transport.addManual': '手動新增交通',
} }
+1 -1
View File
@@ -807,7 +807,7 @@ img[alt="TREK"] {
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); } .collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; } .collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
.collab-note-md-full pre code { padding: 0; background: none; } .collab-note-md-full pre code { padding: 0; background: none; }
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; word-break: break-all; } .collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; }
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); } .collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; } .collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; } .collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
-3
View File
@@ -3,9 +3,6 @@ import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import './index.css' import './index.css'
import { startConnectivityProbe } from './sync/connectivity'
startConnectivityProbe()
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+2 -11
View File
@@ -1240,15 +1240,6 @@ interface SidebarContentProps {
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement { function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation() const { language } = useTranslation()
const statsContentRef = useRef<HTMLDivElement>(null)
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
useEffect(() => {
const el = statsContentRef.current
if (!el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => setStatsWidth(el.offsetWidth))
ro.observe(el)
return () => ro.disconnect()
}, [])
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})` const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a' const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b' const tm = dark ? '#94a3b8' : '#64748b'
@@ -1299,7 +1290,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
// Bucket list content // Bucket list content
const bucketContent = ( const bucketContent = (
<> <>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}> <div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
{bucketList.map(item => ( {bucketList.map(item => (
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}> <div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
{(() => { {(() => {
@@ -1409,7 +1400,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{/* Both tabs always rendered so the wider one sets the panel width */} {/* Both tabs always rendered so the wider one sets the panel width */}
<div style={{ display: 'grid' }}> <div style={{ display: 'grid' }}>
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}> <div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div ref={statsContentRef} className="flex items-stretch justify-center"> <div className="flex items-stretch justify-center">
{/* ═══ SECTION 1: Numbers ═══ */} {/* ═══ SECTION 1: Numbers ═══ */}
{/* Countries hero */} {/* Countries hero */}
-8
View File
@@ -401,10 +401,6 @@ describe('DashboardPage', () => {
const copyButtons = screen.getAllByRole('button', { name: /copy/i }); const copyButtons = screen.getAllByRole('button', { name: /copy/i });
await user.click(copyButtons[0]); await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument(); expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
}); });
@@ -770,10 +766,6 @@ describe('DashboardPage', () => {
expect(copyButtons.length).toBeGreaterThan(0); expect(copyButtons.length).toBeGreaterThan(0);
await user.click(copyButtons[0]); await user.click(copyButtons[0]);
// Confirm the copy dialog
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
await user.click(confirmButton);
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0); expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
}); });
+2 -15
View File
@@ -12,7 +12,6 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
import TimezoneWidget from '../components/Dashboard/TimezoneWidget' import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
import TripFormModal from '../components/Trips/TripFormModal' import TripFormModal from '../components/Trips/TripFormModal'
import ConfirmDialog from '../components/shared/ConfirmDialog' import ConfirmDialog from '../components/shared/ConfirmDialog'
import CopyTripDialog from '../components/shared/CopyTripDialog'
import { useToast } from '../components/shared/Toast' import { useToast } from '../components/shared/Toast'
import { useCountUp } from '../hooks/useCountUp' import { useCountUp } from '../hooks/useCountUp'
import { import {
@@ -700,7 +699,6 @@ export default function DashboardPage(): React.ReactElement {
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false) const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid') const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null) const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const toggleViewMode = () => { const toggleViewMode = () => {
setViewMode(prev => { setViewMode(prev => {
@@ -817,18 +815,14 @@ export default function DashboardPage(): React.ReactElement {
setArchivedTrips(prev => prev.map(update)) setArchivedTrips(prev => prev.map(update))
} }
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip) const handleCopy = async (trip: DashboardTrip) => {
const confirmCopy = async () => {
if (!copyTrip) return
try { try {
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` }) const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
setTrips(prev => sortTrips([data.trip, ...prev])) setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.copied')) toast.success(t('dashboard.toast.copied'))
} catch { } catch {
toast.error(t('dashboard.toast.copyError')) toast.error(t('dashboard.toast.copyError'))
} }
setCopyTrip(null)
} }
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0]
@@ -1211,13 +1205,6 @@ export default function DashboardPage(): React.ReactElement {
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })} message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
/> />
<CopyTripDialog
isOpen={!!copyTrip}
tripTitle={copyTrip?.title || ''}
onClose={() => setCopyTrip(null)}
onConfirm={confirmCopy}
/>
<style>{` <style>{`
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1 } 0%, 100% { opacity: 1 }
+28 -42
View File
@@ -177,24 +177,6 @@ const mockJourneyDetail = {
}, },
], ],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [
{
id: 100,
journey_id: 1,
photo_id: 100,
provider: 'local',
file_path: 'photos/test.jpg',
asset_id: null,
owner_id: null,
thumbnail_path: null,
caption: 'Colosseum',
sort_order: 0,
width: 800,
height: 600,
shared: 1,
created_at: now,
},
],
}; };
// ── MSW Handlers ───────────────────────────────────────────────────────────── // ── MSW Handlers ─────────────────────────────────────────────────────────────
@@ -1486,7 +1468,7 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-074 ────────────────────────────────────────── // ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => { describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
it('clicking "Delete link" calls DELETE and returns to create state', async () => { it('clicking "Remove share link" calls DELETE and returns to create state', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let deleteCalled = false; let deleteCalled = false;
@@ -1511,10 +1493,10 @@ describe('JourneyDetailPage', () => {
await openSettingsDialog(user); await openSettingsDialog(user);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Delete link')).toBeInTheDocument(); expect(screen.getByText('Remove share link')).toBeInTheDocument();
}); });
await user.click(screen.getByText('Delete link')); await user.click(screen.getByText('Remove share link'));
await waitFor(() => { await waitFor(() => {
expect(deleteCalled).toBe(true); expect(deleteCalled).toBe(true);
@@ -1742,14 +1724,13 @@ describe('JourneyDetailPage', () => {
it('renders the empty gallery state when journey has no photos', async () => { it('renders the empty gallery state when journey has no photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
// Override with entries that have no photos and empty gallery // Override with entries that have no photos
const emptyEntry = { const emptyEntry = {
...mockJourneyDetail.entries[0], ...mockJourneyDetail.entries[0],
photos: [], photos: [],
}; };
setupDefaultHandlers({ setupDefaultHandlers({
entries: [emptyEntry], entries: [emptyEntry],
gallery: [],
stats: { entries: 1, photos: 0, places: 1 }, stats: { entries: 1, photos: 0, places: 1 },
}); });
@@ -2000,9 +1981,10 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument(); expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
}); });
// Gallery photos render in a grid; each photo has a group container // The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
const photos = document.querySelectorAll('[class*="aspect-square"]'); // The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
expect(photos.length).toBeGreaterThanOrEqual(1); const dateOverlay = document.querySelector('[class*="opacity-0"]');
expect(dateOverlay).toBeTruthy();
}); });
}); });
@@ -2040,11 +2022,6 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({ setupDefaultHandlers({
entries: [immichEntry, mockJourneyDetail.entries[1]], entries: [immichEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
}); });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
@@ -2079,11 +2056,6 @@ describe('JourneyDetailPage', () => {
setupDefaultHandlers({ setupDefaultHandlers({
entries: [synologyEntry, mockJourneyDetail.entries[1]], entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 }, stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
}); });
render(<JourneyDetailPage />); render(<JourneyDetailPage />);
@@ -2933,7 +2905,7 @@ describe('JourneyDetailPage', () => {
// The permission toggles show Timeline, Gallery, Map labels within the share section // The permission toggles show Timeline, Gallery, Map labels within the share section
// These reuse the same i18n keys as the main tab bar // These reuse the same i18n keys as the main tab bar
expect(screen.getByText('Delete link')).toBeInTheDocument(); expect(screen.getByText('Remove share link')).toBeInTheDocument();
expect(screen.getByText('Copy')).toBeInTheDocument(); expect(screen.getByText('Copy')).toBeInTheDocument();
}); });
}); });
@@ -3293,14 +3265,25 @@ describe('JourneyDetailPage', () => {
// ── FE-PAGE-JOURNEYDETAIL-141 ────────────────────────────────────────── // ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => { describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
it('uploading files in gallery calls gallery upload API', async () => { it('uploading files in gallery creates an entry and uploads photos', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
let createCalled = false;
let uploadCalled = false; let uploadCalled = false;
server.use( server.use(
http.post('/api/journeys/1/gallery/photos', () => { http.post('/api/journeys/1/entries', () => {
createCalled = true;
return HttpResponse.json({
id: 99, journey_id: 1, author_id: 1, type: 'entry',
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
location_lat: null, location_lng: null, mood: null, weather: null,
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
entry_time: null, photos: [], created_at: now, updated_at: now,
});
}),
http.post('/api/journeys/entries/99/photos', () => {
uploadCalled = true; uploadCalled = true;
return HttpResponse.json({ photos: [] }); return HttpResponse.json([]);
}), }),
); );
@@ -3321,6 +3304,9 @@ describe('JourneyDetailPage', () => {
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' }); const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
await user.upload(fileInput, testFile); await user.upload(fileInput, testFile);
await waitFor(() => {
expect(createCalled).toBe(true);
});
await waitFor(() => { await waitFor(() => {
expect(uploadCalled).toBe(true); expect(uploadCalled).toBe(true);
}); });
@@ -3334,9 +3320,9 @@ describe('JourneyDetailPage', () => {
let deleteCalled = false; let deleteCalled = false;
server.use( server.use(
http.delete('/api/journeys/1/gallery/100', () => { http.delete('/api/journeys/photos/100', () => {
deleteCalled = true; deleteCalled = true;
return new HttpResponse(null, { status: 204 }); return HttpResponse.json({ success: true });
}), }),
); );
+112 -150
View File
@@ -1,6 +1,4 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { formatLocationName } from '../utils/formatters'
import { normalizeImageFiles } from '../utils/convertHeic'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { useJourneyStore } from '../store/journeyStore' import { useJourneyStore } from '../store/journeyStore'
@@ -10,7 +8,6 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
import { addListener, removeListener } from '../api/websocket' import { addListener, removeListener } from '../api/websocket'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import JourneyMap from '../components/Journey/JourneyMapAuto' import JourneyMap from '../components/Journey/JourneyMapAuto'
import { DAY_COLORS } from '../components/Journey/dayColors'
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto' import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import MarkdownToolbar from '../components/Journey/MarkdownToolbar' import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
@@ -28,7 +25,7 @@ import {
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView' import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore' import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle' import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
const GRADIENTS = [ const GRADIENTS = [
@@ -70,18 +67,16 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
return groups return groups
} }
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } { function formatDate(d: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00') const date = new Date(d + 'T00:00:00')
// Pass the app's selected locale so weekday/month follow the UI language
// instead of the browser's navigator.language.
return { return {
weekday: date.toLocaleDateString(locale, { weekday: 'long' }), weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
month: date.toLocaleDateString(locale, { month: 'long' }), month: date.toLocaleDateString(undefined, { month: 'long' }),
day: date.getDate(), day: date.getDate(),
} }
} }
function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string { function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
return `/api/photos/${p.photo_id}/${size}` return `/api/photos/${p.photo_id}/${size}`
} }
@@ -89,7 +84,7 @@ export default function JourneyDetailPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t } = useTranslation()
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore() const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
const mapRef = useRef<JourneyMapHandle>(null) const mapRef = useRef<JourneyMapHandle>(null)
const fullMapRef = useRef<JourneyMapHandle>(null) const fullMapRef = useRef<JourneyMapHandle>(null)
@@ -191,9 +186,7 @@ export default function JourneyDetailPage() {
const winner = lastPast || firstAhead const winner = lastPast || firstAhead
if (winner) { if (winner) {
setActiveEntryId(winner.id) setActiveEntryId(winner.id)
if (locatedEntryIdsRef.current.has(winner.id)) { mapRef.current?.highlightMarker(winner.id)
mapRef.current?.highlightMarker(winner.id)
}
} }
} }
const onScroll = () => { const onScroll = () => {
@@ -284,38 +277,16 @@ export default function JourneyDetailPage() {
[current?.entries] [current?.entries]
) )
const sidebarMapItems = useMemo(() => { const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
const allDates = [...new Set( id: String(e.id),
(current?.entries || []) lat: e.location_lat!,
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]') lng: e.location_lng!,
.map(e => e.entry_date) title: e.title || '',
.sort() location_name: e.location_name || '',
)] mood: e.mood,
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date)) created_at: e.entry_date,
const dayCounters = new Map<string, number>() entry_date: e.entry_date,
return sorted.map(e => { })), [mapEntries])
const dayIdx = allDates.indexOf(e.entry_date)
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
dayCounters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
location_name: e.location_name || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, current?.entries])
const locatedEntryIdsRef = useRef(new Set<string>())
useEffect(() => {
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
}, [sidebarMapItems])
const tripDates = useMemo(() => { const tripDates = useMemo(() => {
const dates = new Set<string>() const dates = new Set<string>()
@@ -342,7 +313,7 @@ export default function JourneyDetailPage() {
) )
} }
const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton')) const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
const dayGroups = groupByDate(timelineEntries) const dayGroups = groupByDate(timelineEntries)
const sortedDates = [...dayGroups.keys()].sort() const sortedDates = [...dayGroups.keys()].sort()
@@ -451,7 +422,7 @@ export default function JourneyDetailPage() {
? 'max-w-[1440px] mx-auto px-0 pt-0' ? 'max-w-[1440px] mx-auto px-0 pt-0'
: 'flex w-full overflow-hidden' : 'flex w-full overflow-hidden'
} }
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined} style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
> >
{/* LEFT column (full width on mobile, scrollable feed on desktop) */} {/* LEFT column (full width on mobile, scrollable feed on desktop) */}
<div <div
@@ -459,7 +430,7 @@ export default function JourneyDetailPage() {
className={ className={
isMobile isMobile
? '' ? ''
: 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll' : 'flex-1 overflow-y-auto journey-feed-scroll'
} }
> >
<div className={isMobile ? '' : 'w-full px-8 py-6'}> <div className={isMobile ? '' : 'w-full px-8 py-6'}>
@@ -511,7 +482,7 @@ export default function JourneyDetailPage() {
> >
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />} {hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
</button> </button>
<span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity"> <span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')} {hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
</span> </span>
</div> </div>
@@ -604,14 +575,14 @@ export default function JourneyDetailPage() {
{sortedDates.map((date, dayIdx) => { {sortedDates.map((date, dayIdx) => {
const entries = dayGroups.get(date)! const entries = dayGroups.get(date)!
const fd = formatDate(date, locale) const fd = formatDate(date)
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))] const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
return ( return (
<div key={date} className="flex flex-col gap-3 trek-stagger"> <div key={date} className="flex flex-col gap-3 trek-stagger">
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between"> <div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}> <div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
{dayIdx + 1} {dayIdx + 1}
</div> </div>
<div> <div>
@@ -624,11 +595,7 @@ export default function JourneyDetailPage() {
</div> </div>
{entries.map((entry, idx) => { {entries.map((entry, idx) => {
// Skeletons are just "suggested" places pulled const canReorder = !isMobile && canEditEntries && entries.length > 1
// from the linked trip — they aren't real
// journey entries until the user edits them,
// so reordering them does not make sense.
const canReorder = !isMobile && canEditEntries && entries.length > 1 && entry.type !== 'skeleton'
const move = (direction: -1 | 1) => { const move = (direction: -1 | 1) => {
if (!current) return if (!current) return
const target = idx + direction const target = idx + direction
@@ -640,7 +607,7 @@ export default function JourneyDetailPage() {
.catch(() => toast.error(t('common.errorOccurred'))) .catch(() => toast.error(t('common.errorOccurred')))
} }
return ( return (
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}> <div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}>
{canReorder && ( {canReorder && (
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1"> <div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
<button <button
@@ -694,11 +661,10 @@ export default function JourneyDetailPage() {
> >
<GalleryView <GalleryView
entries={current.entries} entries={current.entries}
gallery={current.gallery || []}
journeyId={current.id} journeyId={current.id}
userId={useAuthStore.getState().user?.id || 0} userId={useAuthStore.getState().user?.id || 0}
trips={current.trips} trips={current.trips}
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })} onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
onRefresh={() => loadJourney(Number(id))} onRefresh={() => loadJourney(Number(id))}
/> />
</div> </div>
@@ -735,7 +701,7 @@ export default function JourneyDetailPage() {
entry={editingEntry} entry={editingEntry}
journeyId={current.id} journeyId={current.id}
tripDates={tripDates} tripDates={tripDates}
galleryPhotos={current.gallery || []} galleryPhotos={current.entries.flatMap(e => e.photos || [])}
onClose={() => setEditingEntry(null)} onClose={() => setEditingEntry(null)}
onSave={async (data) => { onSave={async (data) => {
let entryId = editingEntry.id let entryId = editingEntry.id
@@ -763,8 +729,7 @@ export default function JourneyDetailPage() {
journey={current} journey={current}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }} onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
onOpenInvite={() => { setShowInvite(true) }} onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
onRefresh={() => loadJourney(Number(id))}
/> />
)} )}
@@ -847,7 +812,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
fullMapRef: React.RefObject<JourneyMapHandle | null> fullMapRef: React.RefObject<JourneyMapHandle | null>
onLocationClick: (id: string) => void onLocationClick: (id: string) => void
}) { }) {
const { t, locale } = useTranslation() const { t } = useTranslation()
// group map entries by date // group map entries by date
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>() const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
mapEntries.forEach((e, i) => { mapEntries.forEach((e, i) => {
@@ -903,7 +868,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<div className="px-5 pb-5"> <div className="px-5 pb-5">
{dates.map((date, dayIdx) => { {dates.map((date, dayIdx) => {
const items = byDate.get(date)! const items = byDate.get(date)!
const fd = formatDate(date, locale) const fd = formatDate(date)
return ( return (
<div key={date}> <div key={date}>
@@ -946,7 +911,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span> <span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
</div> </div>
<div className="text-[11px] text-zinc-500 truncate"> <div className="text-[11px] text-zinc-500 truncate">
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''} {e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
</div> </div>
</div> </div>
@@ -973,13 +938,12 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
// ── Gallery View ────────────────────────────────────────────────────────── // ── Gallery View ──────────────────────────────────────────────────────────
function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: { function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
entries: JourneyEntry[] entries: JourneyEntry[]
gallery: GalleryPhoto[]
journeyId: number journeyId: number
userId: number userId: number
trips: JourneyTrip[] trips: JourneyTrip[]
onPhotoClick: (photos: GalleryPhoto[], index: number) => void onPhotoClick: (photos: JourneyPhoto[], index: number) => void
onRefresh: () => void onRefresh: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -1012,7 +976,12 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
})() })()
}, []) }, [])
const allPhotos = gallery const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
for (const e of entries) {
for (const p of e.photos) {
allPhotos.push({ photo: p, entry: e })
}
}
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -1028,10 +997,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
if (!files?.length) return if (!files?.length) return
setGalleryUploading(true) setGalleryUploading(true)
try { try {
const normalized = await normalizeImageFiles(files) // find existing "Gallery" entry or create one. The stored title is the
// literal 'Gallery' (server-side checks look for this exact string) —
// do not send a translated label here.
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
let entryId = galleryEntry?.id
if (!entryId) {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
entryId = entry.id
}
const formData = new FormData() const formData = new FormData()
for (const f of normalized) formData.append('photos', f) for (const f of files) formData.append('photos', f)
await journeyApi.uploadGalleryPhotos(journeyId, formData) await journeyApi.uploadPhotos(entryId, formData)
toast.success(t('journey.photosUploaded', { count: files.length })) toast.success(t('journey.photosUploaded', { count: files.length }))
onRefresh() onRefresh()
} catch { } catch {
@@ -1042,27 +1023,24 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
e.target.value = '' e.target.value = ''
} }
const handleDeletePhoto = async (galleryPhotoId: number) => { const handleDeletePhoto = async (photoId: number) => {
// Optimistic update — remove photo from local state immediately
const store = useJourneyStore.getState() const store = useJourneyStore.getState()
if (!store.current) return if (store.current) {
const updated = {
// Optimistic update — remove from gallery and all entry photo lists
useJourneyStore.setState({
current: {
...store.current, ...store.current,
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
entries: store.current.entries.map(e => ({ entries: store.current.entries.map(e => ({
...e, ...e,
photos: e.photos.filter(p => p.id !== galleryPhotoId), photos: e.photos.filter(p => p.id !== photoId),
})), })).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
}, }
}) useJourneyStore.setState({ current: updated })
}
try { try {
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId) await journeyApi.deletePhoto(photoId)
} catch { } catch {
toast.error(t('common.error')) toast.error(t('common.error'))
onRefresh() onRefresh() // Revert on error
} }
} }
@@ -1110,11 +1088,11 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map((photo, i) => ( {allPhotos.map(({ photo, entry }, i) => (
<div <div
key={photo.id} key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group" className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => onPhotoClick(allPhotos, i)} onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
> >
<img <img
src={photoUrl(photo, 'thumbnail')} src={photoUrl(photo, 'thumbnail')}
@@ -1143,6 +1121,11 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
<p className="text-[10px] text-white truncate">{photo.caption}</p> <p className="text-[10px] text-white truncate">{photo.caption}</p>
</div> </div>
)} )}
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</div>
</div> </div>
))} ))}
</div> </div>
@@ -1155,19 +1138,25 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
userId={userId} userId={userId}
entries={entriesWithContent} entries={entriesWithContent}
trips={trips} trips={trips}
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))} existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
onClose={() => setShowPicker(false)} onClose={() => setShowPicker(false)}
onAdd={async (groups, entryId) => { onAdd={async (groups, entryId) => {
let targetId = entryId
if (!targetId) {
try {
const entry = await journeyApi.createEntry(journeyId, {
title: 'Gallery',
entry_date: new Date().toISOString().split('T')[0],
type: 'entry',
})
targetId = entry.id
} catch { return }
}
let added = 0 let added = 0
for (const group of groups) { for (const group of groups) {
try { try {
if (entryId) { const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase) added += result.added || 0
added += result.added || 0
} else {
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
added += result.added || 0
}
} catch {} } catch {}
} }
if (added > 0) { if (added > 0) {
@@ -1365,7 +1354,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
{entry.location_name && ( {entry.location_name && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden"> <span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <MapPin size={10} className="flex-shrink-0" />
<span className="truncate">{formatLocationName(entry.location_name)}</span> <span className="truncate">{entry.location_name}</span>
</span> </span>
)} )}
{entry.entry_time && ( {entry.entry_time && (
@@ -1408,7 +1397,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2"> <div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
{entry.location_name && ( {entry.location_name && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden"> <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span> <MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span>
</span> </span>
)} )}
{entry.entry_time && ( {entry.entry_time && (
@@ -1487,7 +1476,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
{entry.title || t('journey.detail.newEntry')} {entry.title || t('journey.detail.newEntry')}
</div> </div>
<div className="text-[11px] text-zinc-500 mt-0.5"> <div className="text-[11px] text-zinc-500 mt-0.5">
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''} {entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
</div> </div>
</div> </div>
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0"> <div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
@@ -1771,11 +1760,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
: t('journey.picker.newGallery') : t('journey.picker.newGallery')
return ( return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}> <div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}> <div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white"> <h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
{provider === 'immich' ? 'Immich' : 'Synology Photos'} {provider === 'immich' ? 'Immich' : 'Synology Photos'}
</h2> </h2>
@@ -1785,7 +1774,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Filter bar */} {/* Filter bar */}
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0"> <div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1.5 mb-3"> <div className="flex gap-1.5 mb-3">
{[ {[
@@ -1871,7 +1860,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Add-to entry selector */} {/* Add-to entry selector */}
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0"> <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<div className="relative flex items-center gap-2"> <div className="relative flex items-center gap-2">
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span> <span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
<button <button
@@ -1924,7 +1913,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)) const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
if (selectable.length === 0) return null if (selectable.length === 0) return null
return ( return (
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0"> <div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
<button <button
onClick={() => { onClick={() => {
if (allSelected) { if (allSelected) {
@@ -1949,7 +1938,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
})()} })()}
{/* Photo grid */} {/* Photo grid */}
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0"> <div className="flex-1 overflow-y-auto p-4">
{loading ? ( {loading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
@@ -2022,7 +2011,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0"> <div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400"> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span> <span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
<span className="leading-[18px]">{t('journey.picker.selected')}</span> <span className="leading-[18px]">{t('journey.picker.selected')}</span>
@@ -2168,7 +2157,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
entry: JourneyEntry entry: JourneyEntry
journeyId: number journeyId: number
tripDates: Set<string> tripDates: Set<string>
galleryPhotos: GalleryPhoto[] galleryPhotos: JourneyPhoto[]
onClose: () => void onClose: () => void
onSave: (data: Record<string, unknown>) => Promise<number> onSave: (data: Record<string, unknown>) => Promise<number>
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]> onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
@@ -2194,7 +2183,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : ['']) const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || []) const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
const [pendingFiles, setPendingFiles] = useState<File[]>([]) const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([]) const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
const [showGalleryPick, setShowGalleryPick] = useState(false) const [showGalleryPick, setShowGalleryPick] = useState(false)
@@ -2221,8 +2210,6 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
pendingLinkIds.length > 0 pendingLinkIds.length > 0
) )
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
const handleClose = () => { const handleClose = () => {
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
onClose() onClose()
@@ -2267,8 +2254,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
if (!files?.length) return if (!files?.length) return
// Queue files locally until Save so cancel/close actually discards. This // Queue files locally until Save so cancel/close actually discards. This
// keeps photo behavior consistent with text fields — no silent persistence. // keeps photo behavior consistent with text fields — no silent persistence.
const normalized = await normalizeImageFiles(files) setPendingFiles(prev => [...prev, ...Array.from(files)])
setPendingFiles(prev => [...prev, ...normalized])
} }
return ( return (
@@ -2326,14 +2312,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
)} )}
</div> </div>
{/* Gallery picker directly below buttons. Safari collapses {/* Gallery picker — directly below buttons */}
`aspect-square` items inside an overflow-scroll grid, so
the square is enforced with a padding-top spacer + an
absolutely positioned image (works across all browsers). */}
{showGalleryPick && ( {showGalleryPick && (
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50"> <div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto"> <div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
{availableGalleryPhotos.map(gp => ( {galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
<div <div
key={gp.id} key={gp.id}
onClick={async () => { onClick={async () => {
@@ -2347,13 +2330,12 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
setPhotos(prev => [...prev, gp]) setPhotos(prev => [...prev, gp])
} }
}} }}
className="relative w-full rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all" className="aspect-square rounded-lg overflow-hidden cursor-pointer hover:ring-2 hover:ring-zinc-900 dark:hover:ring-white hover:ring-offset-1 dark:hover:ring-offset-zinc-900 transition-all"
style={{ paddingTop: '100%' }}
> >
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} /> <img src={photoUrl(gp)} alt="" className="w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
</div> </div>
))} ))}
{availableGalleryPhotos.length === 0 && ( {galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div> <div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
)} )}
</div> </div>
@@ -2388,13 +2370,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
<button <button
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
await journeyApi.deletePhoto(p.id)
setPhotos(prev => prev.filter(x => x.id !== p.id)) setPhotos(prev => prev.filter(x => x.id !== p.id))
if (entry.id > 0) {
// unlink from entry; gallery row is preserved
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
} else {
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
}
}} }}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity" className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
> >
@@ -2967,7 +2944,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
onClick={deleteLink} onClick={deleteLink}
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start" className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
> >
{t('share.deleteLink')} Remove share link
</button> </button>
</div> </div>
)} )}
@@ -2975,12 +2952,11 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
) )
} }
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: { function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
journey: JourneyDetail journey: JourneyDetail
onClose: () => void onClose: () => void
onSaved: () => void onSaved: () => void
onOpenInvite: () => void onOpenInvite: () => void
onRefresh: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [title, setTitle] = useState(journey.title) const [title, setTitle] = useState(journey.title)
@@ -2988,10 +2964,6 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [showAddTrip, setShowAddTrip] = useState(false) const [showAddTrip, setShowAddTrip] = useState(false)
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null) const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
const coverRef = useRef<HTMLInputElement>(null) const coverRef = useRef<HTMLInputElement>(null)
const toast = useToast() const toast = useToast()
const navigate = useNavigate() const navigate = useNavigate()
@@ -3050,12 +3022,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
} }
return ( return (
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}> <div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}> <div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2> <h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"> <button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
@@ -3151,7 +3123,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
try { try {
await journeyApi.removeContributor(journey.id, c.user_id) await journeyApi.removeContributor(journey.id, c.user_id)
toast.success(t('journey.contributors.removed')) toast.success(t('journey.contributors.removed'))
onRefresh() onSaved()
} catch { } catch {
toast.error(t('journey.contributors.removeFailed')) toast.error(t('journey.contributors.removeFailed'))
} }
@@ -3202,7 +3174,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />} {journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span> <span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
</button> </button>
<button onClick={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button> <button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40"> <button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
{saving ? t('common.saving') : t('common.save')} {saving ? t('common.saving') : t('common.save')}
</button> </button>
@@ -3249,16 +3221,6 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefr
confirmLabel={t('common.delete')} confirmLabel={t('common.delete')}
danger danger
/> />
<ConfirmDialog
isOpen={showDiscardConfirm}
onClose={() => setShowDiscardConfirm(false)}
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
title={t('common.discardChanges')}
message={t('journey.editor.discardChangesConfirm')}
confirmLabel={t('common.discard')}
danger
/>
</div> </div>
) )
} }
+25 -69
View File
@@ -56,21 +56,6 @@ vi.mock('../components/Journey/PhotoLightbox', () => ({
), ),
})); }));
vi.mock('../components/Journey/MobileMapTimeline', () => ({
default: ({ onEntryClick }: any) => (
<div data-testid="mobile-map-timeline">
<button onClick={() => onEntryClick({ id: 10, title: 'Shibuya Crossing', story: 'The most famous crossing in the world.', entry_date: '2026-03-15', entry_time: '14:00', location_name: 'Shibuya, Tokyo', photos: [] })}>
Open Entry
</button>
</div>
),
}));
const mockIsMobile = { value: false };
vi.mock('../hooks/useIsMobile', () => ({
useIsMobile: () => mockIsMobile.value,
}));
import JourneyPublicPage from './JourneyPublicPage'; import JourneyPublicPage from './JourneyPublicPage';
// ── Fixtures ───────────────────────────────────────────────────────────────── // ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -121,9 +106,6 @@ const mockJourneyData = {
share_gallery: true, share_gallery: true,
share_map: true, share_map: true,
}, },
gallery: [
{ id: 100, journey_id: 1, photo_id: 100, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/temple.jpg', caption: 'Temple entrance', shared: 1, sort_order: 0, created_at: 0 },
],
stats: { stats: {
entries: 2, entries: 2,
photos: 1, photos: 1,
@@ -154,7 +136,6 @@ function setup404() {
beforeEach(() => { beforeEach(() => {
resetAllStores(); resetAllStores();
vi.clearAllMocks(); vi.clearAllMocks();
mockIsMobile.value = false;
}); });
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
@@ -253,20 +234,28 @@ describe('JourneyPublicPage', () => {
} }
}); });
it('FE-PAGE-PUBLICJOURNEY-009: map is always visible in desktop two-column layout', async () => { it('FE-PAGE-PUBLICJOURNEY-009: map tab switches view', async () => {
setupSuccess(); setupSuccess();
render(<JourneyPublicPage />); render(<JourneyPublicPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
}); });
// Desktop two-column: map sidebar is always rendered alongside the timeline; const buttons = screen.getAllByRole('button');
// there is no standalone "Map" tab button on desktop. const mapBtn = buttons.find(
await waitFor(() => { btn => btn.textContent && /map/i.test(btn.textContent),
expect(screen.getByTestId('journey-map')).toBeInTheDocument(); );
}); expect(mapBtn).toBeDefined();
// Timeline entries remain visible (two-column shows both simultaneously) if (mapBtn) {
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument(); fireEvent.click(mapBtn);
// After clicking map tab, the timeline entries should no longer be visible
// and the map view content should be rendered (even if JourneyMap errors internally
// due to jsdom limitations, the tab state switches)
await waitFor(() => {
// Shibuya Crossing (timeline-only) should not appear once map is active
expect(screen.queryByText('Shibuya Crossing')).not.toBeInTheDocument();
});
}
}); });
it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => { it('FE-PAGE-PUBLICJOURNEY-010: shows journey stats', async () => {
@@ -314,18 +303,24 @@ describe('JourneyPublicPage', () => {
}); });
// FE-PAGE-PUBLICJOURNEY-012 // FE-PAGE-PUBLICJOURNEY-012
it('FE-PAGE-PUBLICJOURNEY-012: map component renders with located entries in desktop two-column layout', async () => { it('FE-PAGE-PUBLICJOURNEY-012: tab switching from timeline to map shows map component', async () => {
const user = userEvent.setup();
setupSuccess(); setupSuccess();
render(<JourneyPublicPage />); render(<JourneyPublicPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument(); expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
}); });
// Desktop two-column: map sidebar is always rendered; no tab click required. const mapBtn = screen.getAllByRole('button').find(
btn => btn.textContent && /map/i.test(btn.textContent),
);
expect(mapBtn).toBeDefined();
await user.click(mapBtn!);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('journey-map')).toBeInTheDocument(); expect(screen.getByTestId('journey-map')).toBeInTheDocument();
}); });
// Both fixture entries have coordinates → map receives 2 located entries // Map receives entries with lat/lng
expect(screen.getByTestId('journey-map').textContent).toContain('2'); expect(screen.getByTestId('journey-map').textContent).toContain('2');
}); });
@@ -359,11 +354,6 @@ describe('JourneyPublicPage', () => {
], ],
}, },
], ],
gallery: [
{ id: 200, journey_id: 1, photo_id: 200, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/a.jpg', caption: 'Photo A', shared: 1, sort_order: 0, created_at: 0 },
{ id: 201, journey_id: 1, photo_id: 201, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/b.jpg', caption: 'Photo B', shared: 1, sort_order: 1, created_at: 0 },
{ id: 202, journey_id: 1, photo_id: 202, provider: 'local', asset_id: null, owner_id: null, file_path: 'journey/c.jpg', caption: 'Photo C', shared: 1, sort_order: 2, created_at: 0 },
],
stats: { entries: 1, photos: 3, places: 0 }, stats: { entries: 1, photos: 3, places: 0 },
}; };
@@ -415,40 +405,6 @@ describe('JourneyPublicPage', () => {
expect(statsContainer!.textContent).toContain('7'); expect(statsContainer!.textContent).toContain('7');
}); });
// FE-PAGE-PUBLICJOURNEY-019 — bug #828
it('FE-PAGE-PUBLICJOURNEY-019: mobile public share does not show standalone Map tab', async () => {
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
const buttons = screen.getAllByRole('button');
const mapBtn = buttons.find(btn => btn.textContent && /^map$/i.test(btn.textContent.trim()));
expect(mapBtn).toBeUndefined();
});
// FE-PAGE-PUBLICJOURNEY-020 — bug #826
it('FE-PAGE-PUBLICJOURNEY-020: mobile public share opens entry details on card click', async () => {
const user = userEvent.setup();
mockIsMobile.value = true;
setupSuccess();
render(<JourneyPublicPage />);
await waitFor(() => {
expect(screen.getByText('Tokyo 2026')).toBeInTheDocument();
});
// The MobileMapTimeline mock fires onEntryClick when "Open Entry" is clicked
const openBtn = screen.getByText('Open Entry');
await user.click(openBtn);
// MobileEntryView should slide in with the entry title
await waitFor(() => {
expect(screen.getByText('Shibuya Crossing')).toBeInTheDocument();
});
});
// FE-PAGE-PUBLICJOURNEY-016 // FE-PAGE-PUBLICJOURNEY-016
it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => { it('FE-PAGE-PUBLICJOURNEY-016: language picker opens and switches language', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
+162 -443
View File
@@ -1,23 +1,14 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useEffect, useState, useMemo } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { journeyApi } from '../api/client' import { journeyApi } from '../api/client'
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n' import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
import { useSettingsStore } from '../store/settingsStore' import { useSettingsStore } from '../store/settingsStore'
import { import { List, Grid, MapPin, Camera, BookOpen, Image } from 'lucide-react'
List, Grid, MapPin, Camera, BookOpen, Image, Clock,
Laugh, Smile, Meh, Frown,
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
ThumbsUp, ThumbsDown,
} from 'lucide-react'
import JourneyMap from '../components/Journey/JourneyMap' import JourneyMap from '../components/Journey/JourneyMap'
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
import JournalBody from '../components/Journey/JournalBody' import JournalBody from '../components/Journey/JournalBody'
import PhotoLightbox from '../components/Journey/PhotoLightbox' import PhotoLightbox from '../components/Journey/PhotoLightbox'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline' import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useIsMobile } from '../hooks/useIsMobile' import { useIsMobile } from '../hooks/useIsMobile'
import { formatLocationName } from '../utils/formatters'
import { DAY_COLORS } from '../components/Journey/dayColors'
interface PublicEntry { interface PublicEntry {
id: number id: number
@@ -45,42 +36,15 @@ interface PublicPhoto {
caption?: string | null caption?: string | null
} }
interface PublicGalleryPhoto { function photoUrl(p: PublicPhoto, shareToken: string): string {
id: number return `/api/public/journey/${shareToken}/photos/${p.photo_id}/original`
journey_id: number
photo_id: number
provider?: string
asset_id?: string | null
owner_id?: number | null
file_path?: string | null
caption?: string | null
} }
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = { function formatDate(d: string): { weekday: string; month: string; day: number } {
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
}
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
sunny: { icon: Sun, label: 'Sunny' },
partly: { icon: CloudSun, label: 'Partly cloudy' },
cloudy: { icon: Cloud, label: 'Cloudy' },
rainy: { icon: CloudRain, label: 'Rainy' },
stormy: { icon: CloudLightning, label: 'Stormy' },
cold: { icon: Snowflake, label: 'Cold' },
}
function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
}
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
const date = new Date(d + 'T00:00:00') const date = new Date(d + 'T00:00:00')
return { return {
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }), weekday: date.toLocaleDateString('en', { weekday: 'long' }),
month: date.toLocaleDateString(locale || 'en', { month: 'long' }), month: date.toLocaleDateString('en', { month: 'long' }),
day: date.getDate(), day: date.getDate(),
} }
} }
@@ -106,16 +70,6 @@ export default function JourneyPublicPage() {
const { t } = useTranslation() const { t } = useTranslation()
const [showLangPicker, setShowLangPicker] = useState(false) const [showLangPicker, setShowLangPicker] = useState(false)
const locale = useSettingsStore(s => s.settings.language) || 'en' const locale = useSettingsStore(s => s.settings.language) || 'en'
const mapRef = useRef<JourneyMapHandle>(null)
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
const [viewingEntry, setViewingEntry] = useState<PublicEntry | null>(null)
const handleMarkerClick = useCallback((entryId: string) => {
setActiveEntryId(entryId)
mapRef.current?.highlightMarker(entryId)
document.querySelector(`[data-entry-id="${entryId}"]`)
?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, [])
useEffect(() => { useEffect(() => {
if (!token) return if (!token) return
@@ -126,45 +80,14 @@ export default function JourneyPublicPage() {
}, [token]) }, [token])
const entries = (data?.entries || []) as PublicEntry[] const entries = (data?.entries || []) as PublicEntry[]
const gallery = (data?.gallery || []) as PublicGalleryPhoto[]
const perms = data?.permissions || {} const perms = data?.permissions || {}
const journey = data?.journey || {} const journey = data?.journey || {}
const stats = data?.stats || {} const stats = data?.stats || {}
const timelineEntries = useMemo(() => entries, [entries]) const groupedEntries = useMemo(() => groupByDate(entries), [entries])
const groupedEntries = useMemo(() => groupByDate(timelineEntries), [timelineEntries])
const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries]) const sortedDates = useMemo(() => [...groupedEntries.keys()].sort(), [groupedEntries])
const mapEntries = useMemo( const mapEntries = useMemo(() => entries.filter(e => e.location_lat && e.location_lng), [entries])
() => timelineEntries.filter(e => e.location_lat && e.location_lng), const allPhotos = useMemo(() => entries.flatMap(e => (e.photos || []).map(p => ({ photo: p, entry: e }))), [entries])
[timelineEntries],
)
const allPhotos = gallery
// Map entries with day color/label for colored markers.
// dayIdx is derived from sortedDates (ALL timeline dates) so marker colors
// stay in sync with the timeline day headers even when some days have no locations.
const sidebarMapItems = useMemo(() => {
const counters = new Map<string, number>()
return mapEntries.map(e => {
const dayIdx = sortedDates.indexOf(e.entry_date)
const dayLabel = (counters.get(e.entry_date) ?? 0) + 1
counters.set(e.entry_date, dayLabel)
return {
id: String(e.id),
lat: e.location_lat!,
lng: e.location_lng!,
title: e.title || '',
mood: e.mood,
created_at: e.entry_date,
entry_date: e.entry_date,
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
dayLabel,
}
})
}, [mapEntries, sortedDates])
// Two-column desktop layout: timeline feed left + sticky map right
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
// Set default view based on permissions // Set default view based on permissions
useEffect(() => { useEffect(() => {
@@ -172,11 +95,6 @@ export default function JourneyPublicPage() {
else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map') else if (!perms.share_timeline && !perms.share_gallery && perms.share_map) setView('map')
}, [perms]) }, [perms])
// When switching to desktop two-column, 'map' standalone tab no longer exists
useEffect(() => {
if (desktopTwoColumn && view === 'map') setView('timeline')
}, [desktopTwoColumn, view])
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
@@ -196,262 +114,21 @@ export default function JourneyPublicPage() {
) )
} }
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
const availableViews = [ const availableViews = [
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') }, perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') }, perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
!desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') }, perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[] ].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
// Shared timeline renderer used in both layout modes
const renderTimeline = () => (
<div className="flex flex-col gap-6">
{sortedDates.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
<BookOpen size={24} className="text-zinc-400" />
</div>
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
</div>
)}
{sortedDates.map((date, dayIdx) => {
const dayEntries = groupedEntries.get(date)!
const fd = formatDate(date, locale)
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]
return (
<div key={date}>
{/* Day header */}
<div className="flex items-center gap-3 mb-4">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-[14px] font-bold text-white flex-shrink-0"
style={{ background: dayColor }}
>
{dayIdx + 1}
</div>
<div>
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
</div>
</div>
{/* Entries */}
<div className="flex flex-col gap-4 pl-[52px]">
{dayEntries.map(entry => {
const photos = entry.photos || []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
const prosArr = entry.pros_cons?.pros ?? []
const consArr = entry.pros_cons?.cons ?? []
const hasProscons = prosArr.length > 0 || consArr.length > 0
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption }))
const isActive = activeEntryId === String(entry.id)
return (
<div
key={entry.id}
data-entry-id={String(entry.id)}
onMouseEnter={() => {
if (!desktopTwoColumn) return
setActiveEntryId(String(entry.id))
mapRef.current?.highlightMarker(String(entry.id))
}}
style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined}
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
{/* Photo area */}
{photos.length === 1 && (
<div className="relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
<img src={photoUrl(photos[0], token!)} className="w-full h-64 object-cover" alt="" />
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)', height: '65%' }} />
{entry.location_name && (
<div className="absolute top-3 left-4">
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white">
<MapPin size={10} className="flex-shrink-0" />
<span className="truncate max-w-[200px]">{formatLocationName(entry.location_name)}</span>
</span>
</div>
)}
{entry.title && (
<div className="absolute bottom-4 left-5 right-5 pointer-events-none">
<h3 className="text-[18px] font-bold text-white drop-shadow-sm leading-tight">{entry.title}</h3>
</div>
)}
</div>
)}
{photos.length === 2 && (
<div className="grid grid-cols-2 gap-0.5 overflow-hidden">
{photos.slice(0, 2).map((p, i) => (
<img
key={p.id}
src={photoUrl(p, token!, 'thumbnail')}
alt=""
className="w-full h-52 object-cover cursor-pointer"
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
/>
))}
</div>
)}
{photos.length >= 3 && (
<div className="overflow-hidden flex" style={{ height: 280, gap: 2 }}>
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
<img src={photoUrl(photos[0], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}>
<img src={photoUrl(photos[1], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}>
<img src={photoUrl(photos[2], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
{photos.length > 3 && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<span className="text-white text-[13px] font-semibold flex items-center gap-1">
<Image size={13} /> +{photos.length - 3}
</span>
</div>
)}
</div>
</div>
</div>
)}
{/* Content */}
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
{/* Title (only when no single photo — photo has it in overlay) */}
{photos.length !== 1 && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
)}
{/* Location + time badges */}
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
<div className="flex items-center gap-2 flex-wrap mb-2">
{entry.location_name && (
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-500">
<MapPin size={11} className="flex-shrink-0" />
{formatLocationName(entry.location_name)}
</span>
)}
{entry.entry_time && (
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-400">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</span>
)}
</div>
)}
{entry.entry_time && photos.length === 1 && (
<div className="flex items-center gap-1 text-[11px] text-zinc-400 mb-2">
<Clock size={11} />
{entry.entry_time.slice(0, 5)}
</div>
)}
{/* Story */}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{/* Pros & Cons */}
{hasProscons && (
<div className={`grid gap-3 mt-4 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{prosArr.length > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">
<ThumbsUp size={10} /> Pros
</div>
{prosArr.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{consArr.length > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">
<ThumbsDown size={10} /> Cons
</div>
{consArr.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
{/* Mood + weather */}
{(mood || weather) && (
<div className="flex items-center gap-1.5 pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
{mood && (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${mood.bg} ${mood.text}`}>
<mood.icon size={11} /> {mood.label}
</span>
)}
{weather && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
<weather.icon size={11} /> {weather.label}
</span>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)
// Shared gallery renderer
const renderGallery = () => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map((photo, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div>
))}
</div>
)
// Shared view tab bar
const renderTabs = (views: typeof availableViews) => views.length > 1 && (
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
{views.map(v => (
<button
key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div>
)
return ( return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950"> <div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
{/* Hero */} {/* Hero */}
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}> <div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
{/* Cover image background */}
{journey.cover_image && ( {journey.cover_image && (
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} /> <div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
)} )}
{/* Decorative circles */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} /> <div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} /> <div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
@@ -506,98 +183,160 @@ export default function JourneyPublicPage() {
</div> </div>
{/* Content */} {/* Content */}
{desktopTwoColumn ? ( <div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}> {/* View tabs */}
{/* Left: feed */} {availableViews.length > 1 && (
<div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6"> <div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
{renderTabs(availableViews)} {availableViews.map(v => (
{view === 'timeline' && perms.share_timeline && renderTimeline()} <button
{view === 'gallery' && perms.share_gallery && renderGallery()} key={v.id}
onClick={() => setView(v.id)}
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
view === v.id
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
}`}
>
<v.icon size={13} />
{v.label}
</button>
))}
</div> </div>
)}
{/* Right: sticky map — matches auth page aside proportions */} {/* Mobile combined map+timeline (public, read-only) */}
<aside {isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
className="flex-shrink-0" <MobileMapTimeline
style={{ entries={entries}
width: '44%', minWidth: 420, maxWidth: 760, mapEntries={mapEntries.map(e => ({ id: String(e.id), lat: e.location_lat!, lng: e.location_lng!, title: e.title, mood: e.mood, entry_date: e.entry_date }))}
position: 'sticky', top: 0, height: '100dvh', dark={document.documentElement.classList.contains('dark')}
padding: '16px 16px 16px 0', readOnly
alignSelf: 'flex-start', onEntryClick={() => {}}
}} publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
> />
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm"> )}
<JourneyMap
ref={mapRef}
checkins={[]}
entries={sidebarMapItems as any}
height={9999}
fullScreen
activeMarkerId={activeEntryId ?? undefined}
onMarkerClick={handleMarkerClick}
/>
</div>
</aside>
</div>
) : (
// ── Single-column layout (mobile + desktop-without-map) ───────────────
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
{/* Floating view toggle — visible above the fullscreen map on mobile */} {/* Timeline (desktop, or mobile without map permission) */}
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && ( {(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && (
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}> <div className="flex flex-col gap-6">
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg"> {sortedDates.map(date => {
{availableViews.map(v => ( const dayEntries = groupedEntries.get(date)!
<button const fd = formatDate(date)
key={v.id} return (
onClick={() => setView(v.id)} <div key={date}>
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${ <div className="flex items-center gap-3 mb-4">
view === v.id <div className="w-10 h-10 rounded-xl bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[14px] font-bold">{fd.day}</div>
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' <div>
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300' <div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
}`} <div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
> </div>
<v.icon size={13} /> </div>
{v.label} <div className="flex flex-col gap-4 pl-[52px]">
</button> {dayEntries.map(entry => (
))} <div key={entry.id} className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
{entry.photos.length > 0 && (
<div className="relative">
<img
src={photoUrl(entry.photos[0], token!)}
className="w-full h-52 object-cover cursor-pointer"
alt=""
onClick={() => setLightbox({ photos: entry.photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: 0 })}
/>
{entry.photos.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/60 backdrop-blur text-white rounded-full px-2 py-0.5 text-[10px] font-semibold flex items-center gap-1">
<Image size={10} /> +{entry.photos.length - 1}
</div>
)}
{entry.title && (
<div className="absolute inset-x-0 bottom-0 p-4" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.5) 0%, transparent 100%)' }}>
<h3 className="text-[18px] font-bold text-white drop-shadow-sm">{entry.title}</h3>
</div>
)}
</div>
)}
<div className="px-5 py-4">
{!entry.photos.length && entry.title && (
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white mb-1">{entry.title}</h3>
)}
{entry.location_name && (
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 mb-2">
<MapPin size={11} /> {entry.location_name}
</div>
)}
{entry.story && (
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
<JournalBody text={entry.story} />
</div>
)}
{entry.pros_cons && ((entry.pros_cons.pros?.length ?? 0) > 0 || (entry.pros_cons.cons?.length ?? 0) > 0) && (
<div className="grid grid-cols-2 gap-3 mt-4">
{(entry.pros_cons.pros?.length ?? 0) > 0 && (
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">{t('journey.editor.pros')}</div>
{entry.pros_cons.pros!.map((p, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
</div>
))}
</div>
)}
{(entry.pros_cons.cons?.length ?? 0) > 0 && (
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
<div className="text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">{t('journey.editor.cons')}</div>
{entry.pros_cons.cons!.map((c, i) => (
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
))}
</div>
</div>
)
})}
</div>
)}
{/* Gallery */}
{view === 'gallery' && perms.share_gallery && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
{allPhotos.map(({ photo }, idx) => (
<div
key={photo.id}
className="aspect-square rounded-lg overflow-hidden cursor-pointer"
onClick={() => setLightbox({ photos: allPhotos.map(({ photo: p }) => ({ id: String(p.id), src: photoUrl(p, token!), caption: p.caption })), index: idx })}
>
<img src={photoUrl(photo, token!)} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
</div> </div>
</div> ))}
)} </div>
)}
{renderTabs(availableViews)} {/* Map */}
{view === 'map' && perms.share_map && (
{/* Mobile combined map+timeline (public, read-only) */} <div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && ( <JourneyMap
<MobileMapTimeline checkins={[]}
entries={timelineEntries} entries={mapEntries.map(e => ({
mapEntries={sidebarMapItems as any} id: String(e.id),
dark={document.documentElement.classList.contains('dark')} lat: e.location_lat!,
readOnly lng: e.location_lng!,
onEntryClick={(entry) => setViewingEntry(entry as any)} title: e.title || '',
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`} mood: e.mood,
carouselBottom="calc(env(safe-area-inset-bottom, 16px) + 8px)" created_at: e.entry_date,
entry_date: e.entry_date,
})) as any}
height={500}
/> />
)} </div>
)}
{/* Timeline (desktop, or mobile without map permission) */} </div>
{(!isMobile || !perms.share_map) && view === 'timeline' && perms.share_timeline && renderTimeline()}
{/* Gallery */}
{view === 'gallery' && perms.share_gallery && renderGallery()}
{/* Map (standalone tab — only in single-column mode) */}
{view === 'map' && perms.share_map && (
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
<JourneyMap
checkins={[]}
entries={sidebarMapItems as any}
height={500}
/>
</div>
)}
</div>
)}
{/* Powered by */} {/* Powered by */}
<div className="flex flex-col items-center py-8 gap-2"> <div className="flex flex-col items-center py-8 gap-2">
@@ -618,26 +357,6 @@ export default function JourneyPublicPage() {
onClose={() => setLightbox(null)} onClose={() => setLightbox(null)}
/> />
)} )}
{/* Mobile entry detail view (public share) */}
{viewingEntry && (
<MobileEntryView
entry={viewingEntry as any}
readOnly
publicPhotoUrl={(photoId) => `/api/public/journey/${token}/photos/${photoId}/original`}
onClose={() => setViewingEntry(null)}
onEdit={() => {}}
onDelete={() => {}}
onPhotoClick={(photos, idx) => setLightbox({
photos: photos.map(p => ({
id: String(p.id),
src: photoUrl(p as any, token!, 'original'),
caption: (p as any).caption ?? null,
})),
index: idx,
})}
/>
)}
</div> </div>
) )
} }
@@ -1,105 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '../../tests/helpers/render';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores } from '../../tests/helpers/store';
import LoginPage from './LoginPage';
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
describe('LoginPage — OIDC redirect preservation', () => {
let savedLocation: Location;
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
sessionStorage.clear();
savedLocation = window.location;
});
afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: savedLocation,
});
});
function setSearch(search: string) {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { ...window.location, search },
});
}
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo');
render(<LoginPage />);
await waitFor(() => {
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo');
});
});
it('does not write to sessionStorage when no redirect param is present', async () => {
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument();
});
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
});
});
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
beforeEach(() => {
server.use(
http.get('/api/auth/oidc/exchange', () =>
HttpResponse.json({ token: 'mock-oidc-token' })
),
);
});
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz');
setSearch('?oidc_code=testcode123');
render(<LoginPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
'/oauth/consent?client_id=foo&state=xyz',
{ replace: true },
);
});
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
});
it('falls back to /dashboard when no sessionStorage redirect is set', async () => {
setSearch('?oidc_code=testcode123');
render(<LoginPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true });
});
});
});
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo');
setSearch('?oidc_error=token_failed');
render(<LoginPage />);
await waitFor(() => {
expect(sessionStorage.getItem('oidc_redirect')).toBeNull();
});
});
});
});
+9 -32
View File
@@ -55,12 +55,6 @@ export default function LoginPage(): React.ReactElement {
return '/dashboard' return '/dashboard'
}, []) }, [])
useEffect(() => {
if (redirectTarget !== '/dashboard') {
sessionStorage.setItem('oidc_redirect', redirectTarget)
}
}, [redirectTarget])
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -89,9 +83,7 @@ export default function LoginPage(): React.ReactElement {
window.history.replaceState({}, '', '/login') window.history.replaceState({}, '', '/login')
if (data.token) { if (data.token) {
await loadUser() await loadUser()
const savedRedirect = sessionStorage.getItem('oidc_redirect') || '/dashboard' navigate('/dashboard', { replace: true })
sessionStorage.removeItem('oidc_redirect')
navigate(savedRedirect, { replace: true })
} else { } else {
setError(data.error || t('login.oidcFailed')) setError(data.error || t('login.oidcFailed'))
} }
@@ -112,34 +104,19 @@ export default function LoginPage(): React.ReactElement {
invalid_state: t('login.oidc.invalidState'), invalid_state: t('login.oidc.invalidState'),
} }
setError(errorMessages[oidcError] || oidcError) setError(errorMessages[oidcError] || oidcError)
sessionStorage.removeItem('oidc_redirect')
window.history.replaceState({}, '', '/login') window.history.replaceState({}, '', '/login')
return return
} }
const CONFIG_CACHE_KEY = 'trek_app_config_cache' authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
authApi.getAppConfig?.() if (config) {
.then((config: AppConfig) => { setAppConfig(config)
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ } if (!config.has_users) setMode('register')
return { config, fromCache: false } if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
}) window.location.href = '/api/auth/oidc/login'
.catch(() => {
try {
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false }
} catch { return { config: null as AppConfig | null, fromCache: false } }
})
.then(({ config, fromCache }) => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
// Skip auto-redirect when config is from cache — network is unreliable
// and auto-redirecting to the IdP could loop if the proxy changed.
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login'
}
} }
}) }
})
}, [navigate, t, noRedirect]) }, [navigate, t, noRedirect])
// Language detection chain (runs once on mount, only if user has no saved preference): // Language detection chain (runs once on mount, only if user has no saved preference):
+1 -1
View File
@@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage';
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256'; const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
function setSearchParams(search: string) { function setSearchParams(search: string) {
window.history.pushState({}, '', '/oauth/consent' + search); window.history.pushState({}, '', '/oauth/authorize' + search);
} }
const VALIDATE_OK = { const VALIDATE_OK = {
+170 -173
View File
@@ -34,7 +34,6 @@ export default function OAuthAuthorizePage(): React.ReactElement {
const state = params.get('state') || '' const state = params.get('state') || ''
const codeChallenge = params.get('code_challenge') || '' const codeChallenge = params.get('code_challenge') || ''
const ccMethod = params.get('code_challenge_method') || '' const ccMethod = params.get('code_challenge_method') || ''
const resource = params.get('resource') || undefined
// Load auth state once, then validate // Load auth state once, then validate
useEffect(() => { useEffect(() => {
@@ -44,7 +43,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
useEffect(() => { useEffect(() => {
if (authLoading) return if (authLoading) return
validateRequest() validateRequest()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [authLoading, isAuthenticated]) }, [authLoading, isAuthenticated])
async function validateRequest() { async function validateRequest() {
@@ -58,7 +57,6 @@ export default function OAuthAuthorizePage(): React.ReactElement {
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: ccMethod, code_challenge_method: ccMethod,
response_type: 'code', response_type: 'code',
resource,
}) })
setValidation(result) setValidation(result)
@@ -101,7 +99,6 @@ export default function OAuthAuthorizePage(): React.ReactElement {
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: ccMethod, code_challenge_method: ccMethod,
approved, approved,
resource,
}) })
setPageState('done') setPageState('done')
window.location.href = result.redirect window.location.href = result.redirect
@@ -114,20 +111,20 @@ export default function OAuthAuthorizePage(): React.ReactElement {
function toggleScope(s: string) { function toggleScope(s: string) {
setSelectedScopes(prev => setSelectedScopes(prev =>
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s] prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
) )
} }
function toggleGroup(groupScopes: string[], allSelected: boolean) { function toggleGroup(groupScopes: string[], allSelected: boolean) {
setSelectedScopes(prev => setSelectedScopes(prev =>
allSelected allSelected
? prev.filter(s => !groupScopes.includes(s)) ? prev.filter(s => !groupScopes.includes(s))
: [...new Set([...prev, ...groupScopes])] : [...new Set([...prev, ...groupScopes])]
) )
} }
function handleLoginRedirect() { function handleLoginRedirect() {
const next = '/oauth/consent?' + params.toString() + window.location.hash const next = '/oauth/authorize?' + params.toString()
window.location.href = '/login?redirect=' + encodeURIComponent(next) window.location.href = '/login?redirect=' + encodeURIComponent(next)
} }
@@ -148,212 +145,212 @@ export default function OAuthAuthorizePage(): React.ReactElement {
if (pageState === 'loading' || pageState === 'auto_approving') { if (pageState === 'loading' || pageState === 'auto_approving') {
return ( return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} /> <Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'} {pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
</p> </p>
</div>
</div> </div>
</div>
) )
} }
if (pageState === 'error') { if (pageState === 'error') {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}> <div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" /> <AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1> <h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
</div>
</div> </div>
</div>
) )
} }
if (pageState === 'login_required') { if (pageState === 'login_required') {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}> <div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} /> <Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1> <h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first. <strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
</p> </p>
</div>
<button
onClick={handleLoginRedirect}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
<LogIn className="w-4 h-4" />
Sign in to TREK
</button>
</div> </div>
<button
onClick={handleLoginRedirect}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
<LogIn className="w-4 h-4" />
Sign in to TREK
</button>
</div> </div>
</div>
) )
} }
// pageState === 'consent' // pageState === 'consent'
return ( return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}> <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}> <div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
{/* Left panel — app identity + actions */} {/* Left panel — app identity + actions */}
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}> <div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}> <div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} /> <ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
{validation?.client?.name || clientId}
</h1>
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
This application is requesting access to your TREK account.
</p>
</div>
</div> </div>
<div>
<div className="mt-8 space-y-2"> <p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}> <h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
Only grant access to applications you trust. Your data stays on your server. {validation?.client?.name || clientId}
</h1>
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
This application is requesting access to your TREK account.
</p> </p>
<button
onClick={() => submitConsent(true)}
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{submitting
? 'Authorizing…'
: validation?.scopeSelectable && selectedScopes.length === 0
? 'Select at least one scope'
: validation?.scopeSelectable
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
: 'Approve Access'}
</button>
<button
onClick={() => submitConsent(false)}
disabled={submitting}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
Deny
</button>
</div> </div>
</div> </div>
{/* Right panel — selectable scopes */} <div className="mt-8 space-y-2">
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]"> <p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
<div className="space-y-6"> Only grant access to applications you trust. Your data stays on your server.
{Object.keys(scopesByGroup).length > 0 && ( </p>
<div> <button
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}> onClick={() => submitConsent(true)}
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'} disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
</p> className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
{submitting
? 'Authorizing…'
: validation?.scopeSelectable && selectedScopes.length === 0
? 'Select at least one scope'
: validation?.scopeSelectable
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
: 'Approve Access'}
</button>
<button
onClick={() => submitConsent(false)}
disabled={submitting}
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
Deny
</button>
</div>
</div>
{validation?.scopeSelectable ? ( {/* Right panel — selectable scopes */}
/* DCR client — user selects which scopes to grant */ <div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
<div className="space-y-3"> <div className="space-y-6">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => { {Object.keys(scopesByGroup).length > 0 && (
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s)) <div>
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s)) <p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
return ( {validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}> </p>
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
<input {validation?.scopeSelectable ? (
type="checkbox" /* DCR client — user selects which scopes to grant */
checked={allGroupSelected} <div className="space-y-3">
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }} {Object.entries(scopesByGroup).map(([group, groupScopes]) => {
onChange={() => toggleGroup(groupScopes, allGroupSelected)} const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
className="rounded flex-shrink-0" const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
/> return (
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span> <div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}> <label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
className="rounded flex-shrink-0"
/>
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length} {groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
</span> </span>
</label> </label>
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}> <div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
{groupScopes.map(s => { {groupScopes.map(s => {
const keys = SCOPE_GROUPS[s] const keys = SCOPE_GROUPS[s]
return ( return (
<label <label
key={s} key={s}
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50"> className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
<input <input
type="checkbox" type="checkbox"
checked={selectedScopes.includes(s)} checked={selectedScopes.includes(s)}
onChange={() => toggleScope(s)} onChange={() => toggleScope(s)}
className="mt-0.5 rounded flex-shrink-0" className="mt-0.5 rounded flex-shrink-0"
/> />
<span className="mt-0.5 text-base leading-none flex-shrink-0"> <span className="mt-0.5 text-base leading-none flex-shrink-0">
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'} {s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p> <p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p> <p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
</div>
</label>
)
})}
</div> </div>
</div> </label>
) )
})} })}
</div>
</div> </div>
) : ( )
/* Settings-created client — scopes are fixed, show read-only */ })}
<div className="space-y-5"> </div>
{Object.entries(scopesByGroup).map(([group, groupScopes]) => ( ) : (
<div key={group}> /* Settings-created client — scopes are fixed, show read-only */
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p> <div className="space-y-5">
<div className="space-y-1.5"> {Object.entries(scopesByGroup).map(([group, groupScopes]) => (
{groupScopes.map(s => { <div key={group}>
const keys = SCOPE_GROUPS[s] <p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
return ( <div className="space-y-1.5">
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}> {groupScopes.map(s => {
const keys = SCOPE_GROUPS[s]
return (
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<span className="mt-0.5 text-base leading-none flex-shrink-0"> <span className="mt-0.5 text-base leading-none flex-shrink-0">
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'} {s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p> <p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p> <p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
</div>
</div>
)
})}
</div> </div>
</div> </div>
))} )
</div> })}
)}
</div>
)}
{/* Always-available tools — granted regardless of scopes */}
<div>
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
Always included
</p>
<div className="space-y-1.5">
{[
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
].map(({ name, desc }) => (
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁</span>
<div className="min-w-0">
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
</div> </div>
</div> </div>
))} ))}
</div> </div>
)}
</div>
)}
{/* Always-available tools — granted regardless of scopes */}
<div>
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
Always included
</p>
<div className="space-y-1.5">
{[
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
].map(({ name, desc }) => (
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁</span>
<div className="min-w-0">
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
</div>
</div>
))}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }
+9 -12
View File
@@ -10,9 +10,8 @@ import { getCategoryIcon } from '../components/shared/categoryIcons'
import { createElement } from 'react' import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server' import { renderToStaticMarkup } from 'react-dom/server'
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react' import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
import { isDayInAccommodationRange } from '../utils/dayOrder'
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
function createMarkerIcon(place: any) { function createMarkerIcon(place: any) {
@@ -184,16 +183,14 @@ export default function SharedTripPage() {
{sortedDays.map((day: any, di: number) => { {sortedDays.map((day: any, di: number) => {
const da = assignments[String(day.id)] || [] const da = assignments[String(day.id)] || []
const notes = (dayNotes[String(day.id)] || []) const notes = (dayNotes[String(day.id)] || [])
const dayAssignmentIds: number[] = da.map((a: any) => a.id) const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays }) const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
const merged = getMergedItems({ const merged = [
dayAssignments: da, ...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
dayNotes: notes, ...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
dayTransports: dayTransport, ...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
dayId: day.id, ].sort((a, b) => a.k - b.k)
})
return ( return (
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}> <div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
@@ -214,7 +211,7 @@ export default function SharedTripPage() {
{selectedDay === day.id && merged.length > 0 && ( {selectedDay === day.id && merged.length > 0 && (
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{merged.map((item: any) => { {merged.map((item: any, idx: number) => {
if (item.type === 'transport') { if (item.type === 'transport') {
const r = item.data const r = item.data
const TIcon = TRANSPORT_ICONS[r.type] || Ticket const TIcon = TRANSPORT_ICONS[r.type] || Ticket
-50
View File
@@ -1474,56 +1474,6 @@ describe('TripPlannerPage', () => {
}); });
}); });
describe('FE-PAGE-PLANNER-051: Mobile Plan sidebar stays mounted after onPlaceClick (issue #932)', () => {
it('does not unmount the mobile Plan portal when a place is tapped, preserving scroll position', async () => {
vi.useFakeTimers();
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
seedTripStore({ id: 42 });
seedStore(useTripStore, {
places: [place],
assignments: { '99': [assignment] },
} as any);
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
});
// Open the mobile Plan portal via the bottom-nav Plan button (selector mirrors FE-PAGE-PLANNER-049).
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
b => b.textContent === 'Plan' && !b.getAttribute('title'),
);
expect(mobilePlanBtn).toBeTruthy();
await act(async () => { fireEvent.click(mobilePlanBtn!); });
await waitFor(() => {
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
});
// The mock factory overwrites capturedDayPlanSidebarProps on each mount,
// so current holds the mobile portal instance's props.
const mobileOnPlaceClick = capturedDayPlanSidebarProps.current.onPlaceClick;
expect(typeof mobileOnPlaceClick).toBe('function');
await act(async () => {
mobileOnPlaceClick(place.id, assignment.id);
});
// Invariant: portal must NOT unmount — both instances persist.
// Pre-fix: collapses to 1 (setMobileSidebarOpen(null) destroyed scroll container).
// Post-fix: stays at 2, browser preserves scrollTop on the living DOM node.
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
});
});
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => { describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => { it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
+11 -22
View File
@@ -272,8 +272,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [fitKey, setFitKey] = useState<number>(0) const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null) const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null) const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const mobilePlanScrollTopRef = useRef<number>(0)
const mobilePlacesScrollTopRef = useRef<number>(0)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null) const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null) const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
@@ -345,10 +343,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
}, [tripId]) }, [tripId])
useEffect(() => { useEffect(() => {
if (tripId) { if (tripId) tripActions.loadReservations(tripId)
tripActions.loadReservations(tripId)
tripActions.loadBudgetItems?.(tripId)
}
}, [tripId]) }, [tripId])
useTripWebSocket(tripId) useTripWebSocket(tripId)
@@ -647,7 +642,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null }) const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationUpdated')) toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false) setShowReservationModal(false)
setEditingReservation(null)
if (data.type === 'hotel') { if (data.type === 'hotel') {
accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
} }
@@ -668,20 +662,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleSaveTransport = async (data) => { const handleSaveTransport = async (data) => {
try { try {
if (editingTransport) { if (editingTransport) {
const r = await tripActions.updateReservation(tripId, editingTransport.id, data) await tripActions.updateReservation(tripId, editingTransport.id, data)
toast.success(t('trip.toast.reservationUpdated')) toast.success(t('trip.toast.reservationUpdated'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
} else { } else {
const r = await tripActions.addReservation(tripId, data) await tripActions.addReservation(tripId, data)
toast.success(t('trip.toast.reservationAdded')) toast.success(t('trip.toast.reservationAdded'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
} }
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
} }
@@ -1116,8 +1105,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} /> ? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> : <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} />
} }
</div> </div>
</div> </div>
@@ -1174,7 +1163,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)} )}
{activeTab === 'dateien' && ( {activeTab === 'dateien' && (
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}> <div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
<FileManager <FileManager
files={files || []} files={files || []}
onUpload={(fd) => tripActions.addFile(tripId, fd)} onUpload={(fd) => tripActions.addFile(tripId, fd)}
@@ -1191,7 +1180,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)} )}
{activeTab === 'collab' && ( {activeTab === 'collab' && (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}> <div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} /> <CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
</div> </div>
)} )}
@@ -1201,7 +1190,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> <TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> <TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} /> <ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />} {showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />}
<ConfirmDialog <ConfirmDialog
isOpen={!!deletePlaceId} isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)} onClose={() => setDeletePlaceId(null)}
-31
View File
@@ -355,37 +355,6 @@ describe('journeyStore', () => {
expect(useJourneyStore.getState().loading).toBe(false); expect(useJourneyStore.getState().loading).toBe(false);
}); });
// ── reorderEntries ───────────────────────────────────────────────────────
it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => {
const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 });
const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 });
const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 });
const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] });
useJourneyStore.setState({ current: detail });
server.use(
http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true }))
);
await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]);
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
expect(ids).toEqual([202, 201, 203]);
});
it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => {
const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 });
const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 });
const detail = buildJourneyDetail({ id: 56, entries: [a, b] });
useJourneyStore.setState({ current: detail });
server.use(
http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 }))
);
await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy();
const ids = useJourneyStore.getState().current?.entries.map(e => e.id);
expect(ids).toEqual([211, 212]);
});
// ── clear ──────────────────────────────────────────────────────────────── // ── clear ────────────────────────────────────────────────────────────────
it('FE-STORE-JOURNEY-015: clear resets state', () => { it('FE-STORE-JOURNEY-015: clear resets state', () => {

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