mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
Compare commits
198 Commits
v3.0.0-pre.48
...
v3.0.20
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e49f3467c | |||
| 93b51a0bf5 | |||
| 5b710a429a | |||
| da3cba2de3 | |||
| 7f87dc1ce1 | |||
| e7b419d397 | |||
| de3152ee57 | |||
| de6c0fb781 | |||
| 9f1d05e886 | |||
| 25f326a659 | |||
| 418f3e0bb2 | |||
| 640e5616e9 | |||
| 22f3bf4bfc | |||
| 256f38d8fa | |||
| 9592cc663f | |||
| dba4b28380 | |||
| 51b5bd6966 | |||
| 6072b969d6 | |||
| 4ae4e0c676 | |||
| 51ab30f436 | |||
| 8b53948231 | |||
| 78d6f2ba77 | |||
| bb89d70a94 | |||
| ad9f3887d8 | |||
| 7f1fb508db | |||
| 1f5deeba6c | |||
| ca832e8d88 | |||
| 12fc7f7b68 | |||
| 2770a189df | |||
| 2b162a8cc7 | |||
| 009d89fecf | |||
| 5c3b89578d | |||
| 303e7de433 | |||
| 08eb7f3733 | |||
| 90d86eda61 | |||
| 0eca6d54a1 | |||
| bc1fb71391 | |||
| cb425fb397 | |||
| 35ed712d46 | |||
| 4923973380 | |||
| 8342cf3010 | |||
| 2a37eeccb3 | |||
| ae0e59d9f1 | |||
| 50bb7573fd | |||
| b852317c84 | |||
| 4436b6f673 | |||
| 311647fd46 | |||
| 28dbd86d03 | |||
| 842d9760df | |||
| 58218ff5f6 | |||
| 83be5fc92a | |||
| 7798d2a3fd | |||
| ec1ed60117 | |||
| ed4c21eade | |||
| 9093948ff6 | |||
| 2cea4d73aa | |||
| a2a6f52e6e | |||
| 0978b40b6d | |||
| 6155b6dc86 | |||
| 314486325e | |||
| 523bca3a20 | |||
| d5be528d4b | |||
| 3ada075b1a | |||
| afce302b59 | |||
| 8e8433fa9d | |||
| ff42fa0b8c | |||
| ccea7f7a65 | |||
| 45a5b4e588 | |||
| 82cce365f7 | |||
| ed7e2badca | |||
| ba7b99fb7d | |||
| 71aa8f8051 | |||
| 7c9e945b8c | |||
| f6b3931bc4 | |||
| 9e3041305c | |||
| 78fc557143 | |||
| 8a2fec8de0 | |||
| e109dc0b51 | |||
| 88d980c657 | |||
| 3f489880da | |||
| 45fa6fd0d3 | |||
| a8c27f9d4a | |||
| 288d33ba42 | |||
| e7fb78dc1e | |||
| 4d3bf390a5 | |||
| 001b2365a1 | |||
| 7d5dadc441 | |||
| c912ad4b01 | |||
| bd6cd55a13 | |||
| 757764d046 | |||
| 94e64acc34 | |||
| 70ba24bfe1 | |||
| 32f431e879 | |||
| 906d8821a4 | |||
| 82b16a4bf5 | |||
| 069269e69c | |||
| 534149ba22 | |||
| 2dd6e04b44 | |||
| 0e3d9f6ddc | |||
| 3b7442c2d5 | |||
| 78b45d7c19 | |||
| 9e5100c71c | |||
| fccf13a7e2 | |||
| 09431f725c | |||
| 13162c0920 | |||
| e25b513d0b | |||
| 9012bffabc | |||
| 24a85b0f91 | |||
| 43a503b593 | |||
| a81fe3da0a | |||
| 70ba4d5435 | |||
| 881b9d0939 | |||
| 758de855bf | |||
| 9652874bbd | |||
| 840f5e82aa | |||
| d59b3334dc | |||
| 5a64d8994e | |||
| e6222894e9 | |||
| 9d48c06068 | |||
| 9f70b56a3a | |||
| 232dc78cc9 | |||
| d2c44380a4 | |||
| 2f9d7adf4a | |||
| ba4a64241b | |||
| ee14f706c8 | |||
| 1cc43f63df | |||
| 3450bd59f8 | |||
| 457d436cf6 | |||
| 1127efb9c4 | |||
| 0a98d3c2e7 | |||
| 5eaf7492dc | |||
| ee31c78db8 | |||
| edf14e2ebc | |||
| 2aad8f465c | |||
| 16b81a8356 | |||
| 5984adb2ea | |||
| f8eb1915fe | |||
| b556c636eb | |||
| b20db1428d | |||
| 4a5a59cb78 | |||
| 20bf9c2312 | |||
| 9f57ab4517 | |||
| 292e443dbe | |||
| 2d0414b4a3 | |||
| e612de9143 | |||
| c857d38bcd | |||
| d7a71c0572 | |||
| 58c061e653 | |||
| 22d1d06d39 | |||
| 290f566daa | |||
| 8ca2507050 | |||
| 9c666a0aaf | |||
| b3f2f7308a | |||
| af9b31c1ff | |||
| d7d1493289 | |||
| 54e042b736 | |||
| 0ba31847eb | |||
| 26ab39dc21 | |||
| 00be0eab05 | |||
| ed97bb1deb | |||
| 51387b0af1 | |||
| 1559ed12bd | |||
| c1b9d11173 | |||
| 2ab8b401fb | |||
| 49af7a8b0d | |||
| dd90c6d424 | |||
| 3d887f15ab | |||
| 82bb08e685 | |||
| 4f3368502a | |||
| 0d534f13cf | |||
| ffa10cac65 | |||
| b85f8c5bca | |||
| da39b570eb | |||
| 151950d08a | |||
| e562d7a7ec | |||
| d0383c06c3 | |||
| 5978eec270 | |||
| 242d1bf8d4 | |||
| 4a8260dfbc | |||
| 076a752ee7 | |||
| 545d62c400 | |||
| f8542b4d87 | |||
| c2fea0a26a | |||
| 25bdf56d16 | |||
| d07b508a77 | |||
| 9ddb2f4cd0 | |||
| 5691149a82 | |||
| 4974013995 | |||
| bc192d3106 | |||
| 4db6cbef22 | |||
| f79385cf2a | |||
| db2c11e4a5 | |||
| e57c6773fc | |||
| 4bdc032f97 | |||
| 777b68f87b | |||
| 66a7de09c1 | |||
| a19ae9e653 | |||
| 38f4c9aecb |
@@ -30,3 +30,7 @@ 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/
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# Normalize line endings to LF on commit
|
# Normalize line endings to LF on commit
|
||||||
* text=auto eol=lf
|
* text=auto eol=lf
|
||||||
|
|
||||||
# Explicitly enforce LF for source files
|
# Explicitly enforce LF for source files
|
||||||
*.ts text eol=lf
|
*.ts text eol=lf
|
||||||
*.tsx text eol=lf
|
*.tsx text eol=lf
|
||||||
@@ -14,7 +13,6 @@
|
|||||||
*.yaml text eol=lf
|
*.yaml text eol=lf
|
||||||
*.py text eol=lf
|
*.py text eol=lf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
# Binary files — no line ending conversion
|
# Binary files — no line ending conversion
|
||||||
*.png binary
|
*.png binary
|
||||||
*.jpg binary
|
*.jpg binary
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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
|
||||||
@@ -60,6 +62,7 @@ body:
|
|||||||
- Docker (standalone)
|
- Docker (standalone)
|
||||||
- Kubernetes / Helm
|
- Kubernetes / Helm
|
||||||
- Unraid template
|
- Unraid template
|
||||||
|
- Proxmox Community Script
|
||||||
- Sources
|
- Sources
|
||||||
- Other
|
- Other
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
@@ -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`
|
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||||
- [ ] 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,9 +26,36 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ 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,6 +21,39 @@ 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')) {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
+6
-1
@@ -3,6 +3,8 @@ 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
|
||||||
@@ -58,4 +60,7 @@ coverage
|
|||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
.scannerwork
|
.scannerwork
|
||||||
test-data
|
test-data
|
||||||
|
|
||||||
|
.run
|
||||||
|
.full-review
|
||||||
+2
-2
@@ -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/P7TUxHJs). 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/NhZBDSd4qW). 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`
|
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
|
||||||
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
|
||||||
|
|||||||
+7
-4
@@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build React client
|
# Stage 1: Build React client
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:24-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:22-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -15,13 +15,16 @@ 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 && \
|
||||||
apk del python3 make g++
|
rm package-lock.json && \
|
||||||
|
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 mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
RUN rm -f package-lock.json && \
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ structured API.
|
|||||||
- [Limitations & Important Notes](#limitations--important-notes)
|
- [Limitations & Important Notes](#limitations--important-notes)
|
||||||
- [Resources (read-only)](#resources-read-only)
|
- [Resources (read-only)](#resources-read-only)
|
||||||
- [Tools (read-write)](#tools-read-write)
|
- [Tools (read-write)](#tools-read-write)
|
||||||
|
- [Compound Tools](#compound-tools)
|
||||||
- [Prompts](#prompts)
|
- [Prompts](#prompts)
|
||||||
- [Example](#example)
|
- [Example](#example)
|
||||||
|
|
||||||
@@ -52,10 +53,11 @@ management required — just provide the server URL:
|
|||||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||||
|
|
||||||
**What happens automatically:**
|
**What happens automatically:**
|
||||||
1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
|
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
|
||||||
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
|
||||||
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||||
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
|
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||||
|
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
|
||||||
|
|
||||||
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
||||||
> discovery to work correctly.
|
> discovery to work correctly.
|
||||||
@@ -140,13 +142,17 @@ that match your granted scopes for that session.
|
|||||||
| `vacay:write` | Manage vacation plans | Vacation |
|
| `vacay:write` | Manage vacation plans | Vacation |
|
||||||
| `geo:read` | Maps & geocoding | Geo |
|
| `geo:read` | Maps & geocoding | Geo |
|
||||||
| `weather:read` | Weather forecasts | Weather |
|
| `weather:read` | Weather forecasts | Weather |
|
||||||
|
| `journey:read` | View journeys | Journey |
|
||||||
|
| `journey:write` | Manage journeys | Journey |
|
||||||
|
| `journey:share` | Manage journey share links | Journey |
|
||||||
|
|
||||||
**Scope rules:**
|
**Scope rules:**
|
||||||
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
|
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
|
||||||
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
|
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
|
||||||
|
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
|
||||||
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
|
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
|
||||||
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
|
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
|
||||||
- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled.
|
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -167,7 +173,7 @@ that match your granted scopes for that session.
|
|||||||
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
|
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
|
||||||
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
|
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
|
||||||
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||||
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. |
|
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -194,7 +200,6 @@ making changes.
|
|||||||
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||||
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||||
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||||
| Files | `trek://trips/{tripId}/files` | Files attached to a trip (excludes trashed files) |
|
|
||||||
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
|
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
|
||||||
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||||
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||||
@@ -214,6 +219,10 @@ These resources are only available when the corresponding addon is enabled by an
|
|||||||
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
|
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
|
||||||
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
|
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
|
||||||
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
|
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
|
||||||
|
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
|
||||||
|
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
|
||||||
|
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
|
||||||
|
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -226,7 +235,23 @@ trip in a single call.
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, files, and poll/message counts. Use this as your context loader. |
|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
|
||||||
|
|
||||||
|
### Compound Tools
|
||||||
|
|
||||||
|
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
|
||||||
|
|
||||||
|
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
|
||||||
|
|
||||||
|
| Tool | Wraps | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
|
||||||
|
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
|
||||||
|
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
|
||||||
|
|
||||||
|
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Trips
|
### Trips
|
||||||
|
|
||||||
@@ -247,14 +272,18 @@ trip in a single call.
|
|||||||
|
|
||||||
### Places
|
### Places
|
||||||
|
|
||||||
|
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------------------|--------------------------------------------------------------------------------------------------|
|
|------------------|--------------------------------------------------------------------------------------------------|
|
||||||
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
||||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
||||||
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||||
| `delete_place` | Remove a place from a trip. |
|
| `delete_place` | Remove a place from a trip. |
|
||||||
| `list_categories`| List all available place categories with id, name, icon and color. |
|
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
|
||||||
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
|
||||||
|
| `list_categories` | List all available place categories with id, name, icon and color. |
|
||||||
|
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
||||||
|
|
||||||
### Day Planning
|
### Day Planning
|
||||||
|
|
||||||
@@ -273,24 +302,40 @@ trip in a single call.
|
|||||||
|
|
||||||
### Accommodations
|
### Accommodations
|
||||||
|
|
||||||
|
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------------------------|------------------------------------------------------------------------------------------|
|
|------------------------|------------------------------------------------------------------------------------------|
|
||||||
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
|
||||||
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
|
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
|
||||||
| `delete_accommodation` | Delete an accommodation record from a trip. |
|
| `delete_accommodation` | Delete an accommodation record from a trip. |
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
|
||||||
|
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
|
||||||
|
| `delete_transport` | Delete a transport booking from a trip. |
|
||||||
|
|
||||||
### Reservations
|
### Reservations
|
||||||
|
|
||||||
| Tool | Description |
|
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
|
||||||
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
||||||
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
| Tool | Description |
|
||||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||||
| `reorder_reservations` | Update the display order of reservations within a day. |
|
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||||
|
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
|
||||||
|
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||||
|
|
||||||
### Budget
|
### Budget
|
||||||
|
|
||||||
|
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|----------------------------|---------------------------------------------------------------------------------------|
|
|----------------------------|---------------------------------------------------------------------------------------|
|
||||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||||
@@ -370,7 +415,14 @@ trip in a single call.
|
|||||||
| `get_weather` | Get weather forecast for a location and date. |
|
| `get_weather` | Get weather forecast for a location and date. |
|
||||||
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
|
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
|
||||||
|
|
||||||
### Collab Notes
|
### Airports
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
|
||||||
|
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
|
||||||
|
|
||||||
|
### Collab Notes _(Collab addon required)_
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|----------------------|-------------------------------------------------------------------------------------------------|
|
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||||
@@ -392,14 +444,14 @@ trip in a single call.
|
|||||||
| `delete_collab_message`| Delete a chat message (own messages only). |
|
| `delete_collab_message`| Delete a chat message (own messages only). |
|
||||||
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
|
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
|
||||||
|
|
||||||
### Bucket List
|
### Bucket List _(Atlas addon required)_
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|---------------------------|--------------------------------------------------------------------------------------------|
|
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||||
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||||
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||||
|
|
||||||
### Atlas
|
### Atlas _(Atlas addon required)_
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|--------------------------|---------------------------------------------------------------------------------|
|
|--------------------------|---------------------------------------------------------------------------------|
|
||||||
@@ -444,6 +496,33 @@ trip in a single call.
|
|||||||
| `list_holiday_countries` | List countries available for public holiday calendars. |
|
| `list_holiday_countries` | List countries available for public holiday calendars. |
|
||||||
| `list_holidays` | List public holidays for a country and year. |
|
| `list_holidays` | List public holidays for a country and year. |
|
||||||
|
|
||||||
|
### Journey _(Journey addon required)_
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `list_journeys` | List all journeys owned or contributed to by the current user. |
|
||||||
|
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
|
||||||
|
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
|
||||||
|
| `update_journey` | Update a journey's title, subtitle, or status. |
|
||||||
|
| `delete_journey` | Delete a journey. |
|
||||||
|
| `add_journey_trip` | Link an existing trip to a journey. |
|
||||||
|
| `remove_journey_trip` | Remove a trip from a journey. |
|
||||||
|
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
|
||||||
|
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
|
||||||
|
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
|
||||||
|
| `delete_journey_entry` | Remove an entry from a journey. |
|
||||||
|
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
|
||||||
|
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
|
||||||
|
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
|
||||||
|
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
|
||||||
|
| `remove_journey_contributor` | Remove a contributor from a journey. |
|
||||||
|
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
|
||||||
|
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
|
||||||
|
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
|
||||||
|
| `get_journey_share_link` | Get the current public share link for a journey. |
|
||||||
|
| `create_journey_share_link` | Create or update the public share link for a journey. |
|
||||||
|
| `delete_journey_share_link` | Revoke the public share link for a journey. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prompts
|
## Prompts
|
||||||
|
|||||||
@@ -1,121 +1,174 @@
|
|||||||
<p align="center">
|
<div align="center">
|
||||||
<picture>
|
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
|
||||||
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
|
||||||
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
|
|
||||||
</picture>
|
|
||||||
<br />
|
|
||||||
<em>Your Trips. Your Plan.</em>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<picture>
|
||||||
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
|
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||||
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
</picture>
|
||||||
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
<br />
|
||||||
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
|
||||||
<br />
|
|
||||||
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
|
|
||||||
</p>
|
|
||||||
|
|
||||||

|
<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.
|
||||||
|
|
||||||
|
<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://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://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 />
|
||||||
|
<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://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||||
|
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||||
|
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||||
|
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>More Screenshots</summary>
|
<summary><b>See all features</b></summary>
|
||||||
|
|
||||||
| | |
|
<table>
|
||||||
|---|---|
|
<tr>
|
||||||
|  |  |
|
<td width="50%" valign="top">
|
||||||
|  |  |
|
|
||||||
|  | |
|
#### 🧭 Trip planning
|
||||||
|
|
||||||
|
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||||
|
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||||
|
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||||
|
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||||
|
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||||
|
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||||
|
- **Category filter** — show only matching pins on the map
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🧳 Travel management
|
||||||
|
|
||||||
|
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files
|
||||||
|
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||||
|
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||||
|
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||||
|
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||||
|
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 👥 Collaboration
|
||||||
|
|
||||||
|
- **Real-time sync** — WebSocket. Changes appear instantly across all connected users
|
||||||
|
- **Multi-user trips** — invite members with role-based access
|
||||||
|
- **Invite links** — one-time or reusable links with expiry
|
||||||
|
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
|
- **2FA** — TOTP + backup codes
|
||||||
|
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 📱 Mobile & PWA
|
||||||
|
|
||||||
|
- **Installable** — iOS and Android, straight from the browser, no App Store needed
|
||||||
|
- **Offline support** — Service Worker caches tiles, API, uploads via Workbox
|
||||||
|
- **Native feel** — fullscreen standalone, themed status bar, splash screen
|
||||||
|
- **Touch optimised** — mobile-specific layouts with safe-area handling
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🧩 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
|
||||||
|
- **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
|
||||||
|
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||||
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
#### 🤖 AI / MCP
|
||||||
|
|
||||||
|
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||||
|
- **Granular scopes** — 27 OAuth scopes across 13 permission groups
|
||||||
|
- **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`
|
||||||
|
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" valign="top">
|
||||||
|
|
||||||
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Features
|
<br />
|
||||||
|
|
||||||
### Trip Planning
|
## Get started in 30 seconds
|
||||||
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
|
||||||
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
|
|
||||||
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
|
|
||||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
|
||||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
|
||||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
|
||||||
- **Map Category Filter** — Filter places by category and see only matching pins on the map
|
|
||||||
|
|
||||||
### Travel Management
|
|
||||||
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
|
|
||||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
|
||||||
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
|
|
||||||
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
|
|
||||||
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
|
|
||||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
|
||||||
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
|
|
||||||
|
|
||||||
### Mobile & PWA
|
|
||||||
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
|
||||||
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
|
|
||||||
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
|
|
||||||
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
|
|
||||||
|
|
||||||
### Collaboration
|
|
||||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
|
||||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
|
||||||
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
|
|
||||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
|
||||||
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
|
|
||||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
|
||||||
|
|
||||||
### Addons (modular, admin-toggleable)
|
|
||||||
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
|
||||||
- **Atlas** — Interactive world map with visited countries, bucket list with planned travel dates, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
|
||||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
|
||||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
|
||||||
|
|
||||||
### AI / MCP Integration
|
|
||||||
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
|
|
||||||
- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access
|
|
||||||
- **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation
|
|
||||||
- **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context
|
|
||||||
- **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
|
|
||||||
|
|
||||||
### Customization & Admin
|
|
||||||
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
|
|
||||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
|
||||||
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Indonesian, Arabic (with RTL support)
|
|
||||||
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
|
|
||||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
|
||||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
|
|
||||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
|
||||||
- **PWA**: vite-plugin-pwa + Workbox
|
|
||||||
- **Real-Time**: WebSocket (`ws`)
|
|
||||||
- **State**: Zustand
|
|
||||||
- **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
|
|
||||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
|
||||||
- **Weather**: Open-Meteo API (free, no key required)
|
|
||||||
- **Icons**: lucide-react
|
|
||||||
|
|
||||||
## Helm (Kubernetes)
|
|
||||||
|
|
||||||
A hosted Helm repository is available:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
helm repo add trek https://mauriceboe.github.io/TREK
|
|
||||||
helm repo update
|
|
||||||
helm install trek trek/trek
|
|
||||||
```
|
|
||||||
|
|
||||||
See [`charts/README.md`](charts/README.md) for configuration options.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||||
@@ -123,19 +176,40 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs on port `3000`. The first user to register becomes the admin.
|
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`).
|
||||||
|
|
||||||
### Install as App (PWA)
|
<div align="center">
|
||||||
|
|
||||||
TREK works as a Progressive Web App — no App Store needed:
|
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||||
|
|
||||||
1. Open your TREK instance in the browser (HTTPS required)
|
</div>
|
||||||
2. **iOS**: Share button → "Add to Home Screen"
|
|
||||||
3. **Android**: Menu → "Install app" or "Add to Home Screen"
|
<br />
|
||||||
4. TREK launches fullscreen with its own icon, just like a native app
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<h2 id="docker-compose-production">Docker Compose (production)</h2>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Docker Compose (recommended for production)</summary>
|
<summary>Full compose example with secure defaults</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -158,30 +232,19 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32
|
||||||
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
- TZ=${TZ:-UTC}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-}
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- APP_URL=${APP_URL:-} # required for OIDC + email links
|
||||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
# - FORCE_HTTPS=true # behind a TLS-terminating proxy
|
||||||
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
|
# - TRUST_PROXY=1
|
||||||
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
|
# - OIDC_ISSUER=https://auth.example.com
|
||||||
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
|
# - OIDC_CLIENT_ID=trek
|
||||||
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
|
# - OIDC_CLIENT_SECRET=supersecret
|
||||||
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
|
# - OIDC_DISPLAY_NAME=SSO
|
||||||
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
|
# - OIDC_ADMIN_CLAIM=groups
|
||||||
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
|
# - OIDC_ADMIN_VALUE=app-trek-admins
|
||||||
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
|
|
||||||
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
|
|
||||||
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
|
|
||||||
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
|
|
||||||
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
|
|
||||||
# - OIDC_DISCOVERY_URL= # Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik)
|
|
||||||
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
|
|
||||||
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
|
|
||||||
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
|
|
||||||
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
|
||||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
@@ -194,29 +257,49 @@ services:
|
|||||||
start_period: 15s
|
start_period: 15s
|
||||||
```
|
```
|
||||||
|
|
||||||
This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
|
Then:
|
||||||
|
|
||||||
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
|
|
||||||
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
|
|
||||||
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
|
|
||||||
|
|
||||||
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### Updating
|
<br />
|
||||||
|
|
||||||
**Docker Compose** (recommended):
|
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo add trek https://mauriceboe.github.io/TREK
|
||||||
|
helm repo update
|
||||||
|
helm install trek trek/trek
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||||
|
|
||||||
|
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||||
|
|
||||||
|
TREK works as a Progressive Web App — no App Store needed.
|
||||||
|
|
||||||
|
1. Open TREK in the browser (HTTPS required)
|
||||||
|
2. **iOS**: Share ▸ *Add to Home Screen*
|
||||||
|
3. **Android**: Menu ▸ *Install app* (or *Add to Home Screen*)
|
||||||
|
|
||||||
|
TREK then launches fullscreen with its own icon, just like a native app.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
**Docker Compose:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
**Docker run** — reuse the original volume paths:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull mauriceboe/trek
|
docker pull mauriceboe/trek
|
||||||
@@ -224,27 +307,23 @@ docker rm -f trek
|
|||||||
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||||
|
|
||||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||||
|
|
||||||
### Rotating the Encryption Key
|
<h3>Rotating the Encryption Key</h3>
|
||||||
|
|
||||||
If you need to rotate `ENCRYPTION_KEY` (e.g. you are upgrading from a version that derived encryption from `JWT_SECRET`), use the migration script to re-encrypt all stored secrets under the new key without starting the app:
|
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
docker exec -it trek node --import tsx scripts/migrate-encryption.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
The script will prompt for your old and new keys interactively (input is not echoed). It creates a timestamped database backup before making any changes and exits with a non-zero code if anything fails.
|
The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed).
|
||||||
|
|
||||||
**Upgrading from a previous version?** Your old JWT secret is in `./data/.jwt_secret`. Use its contents as the "old key" and your new `ENCRYPTION_KEY` value as the "new key".
|
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||||
|
|
||||||
### Reverse Proxy (recommended)
|
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||||
|
|
||||||
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
|
||||||
|
|
||||||
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Nginx</summary>
|
<summary>Nginx</summary>
|
||||||
@@ -260,8 +339,20 @@ server {
|
|||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name trek.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
|
|
||||||
ssl_certificate /path/to/fullchain.pem;
|
ssl_certificate /etc/ssl/fullchain.pem;
|
||||||
ssl_certificate_key /path/to/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 500m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location /ws {
|
location /ws {
|
||||||
proxy_pass http://localhost:3000;
|
proxy_pass http://localhost:3000;
|
||||||
@@ -269,21 +360,7 @@ 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_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_read_timeout 86400;
|
proxy_read_timeout 86400;
|
||||||
# File uploads are capped at 50 MB; backup restore ZIPs can include the full
|
|
||||||
# uploads directory and may exceed that — raise this value if restores fail.
|
|
||||||
client_max_body_size 500m;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -293,17 +370,24 @@ server {
|
|||||||
<details>
|
<details>
|
||||||
<summary>Caddy</summary>
|
<summary>Caddy</summary>
|
||||||
|
|
||||||
Caddy handles WebSocket upgrades automatically:
|
```caddy
|
||||||
|
|
||||||
```
|
|
||||||
trek.yourdomain.com {
|
trek.yourdomain.com {
|
||||||
reverse_proxy localhost:3000
|
reverse_proxy localhost:3000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Caddy handles TLS and WebSockets automatically.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Environment Variables
|
<br />
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Full reference</b></summary>
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
@@ -313,58 +397,47 @@ trek.yourdomain.com {
|
|||||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||||
| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `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 (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
|
| `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` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
|
| `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` |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
|
| `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 |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
|
| `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` |
|
||||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
|
| `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` |
|
||||||
|
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||||
| **OIDC / SSO** | | |
|
| **OIDC / SSO** | | |
|
||||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
|
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
|
||||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||||
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
|
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
|
||||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint. Useful for providers that expose it at a non-standard path (e.g. Authentik: `https://auth.example.com/application/o/trek/.well-known/openid-configuration`) | — |
|
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
|
||||||
| **Initial Setup** | | |
|
| **Initial setup** | | |
|
||||||
| `ADMIN_EMAIL` | Email for the first admin account created on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is generated and printed to the server log. Has no effect once any user exists. | `admin@trek.local` |
|
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||||
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
|
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||||
| **Other** | | |
|
| **Other** | | |
|
||||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||||
|
|
||||||
## Optional API Keys
|
</details>
|
||||||
|
|
||||||
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
<br />
|
||||||
|
|
||||||
### Google Maps (Place Search & Photos)
|
|
||||||
|
|
||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
2. Create a project and enable the **Places API (New)**
|
|
||||||
3. Create an API key under Credentials
|
|
||||||
4. In TREK: Admin Panel → Settings → Google Maps
|
|
||||||
|
|
||||||
## Building from Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/mauriceboe/TREK.git
|
|
||||||
cd TREK
|
|
||||||
docker build -t trek .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data & Backups
|
## Data & Backups
|
||||||
|
|
||||||
- **Database**: SQLite, stored in `./data/travel.db`
|
- **Database** — SQLite, stored in `./data/travel.db`
|
||||||
- **Uploads**: Stored in `./uploads/`
|
- **Uploads** — stored in `./uploads/`
|
||||||
- **Logs**: `./data/logs/trek.log` (auto-rotated)
|
- **Logs** — `./data/logs/trek.log` (auto-rotated)
|
||||||
- **Backups**: Create and restore via Admin Panel
|
- **Backups** — create and restore via Admin Panel
|
||||||
- **Auto-Backups**: Configurable schedule and retention in Admin Panel
|
- **Auto-Backups** — configurable schedule and retention in Admin Panel
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE)
|
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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. Email: **mauriceboe@icloud.com**
|
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
|
||||||
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
@@ -0,0 +1,121 @@
|
|||||||
|
# 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).
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/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"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 2.9.14
|
version: 3.0.20
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "2.9.14"
|
appVersion: "3.0.20"
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ 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 }}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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"
|
||||||
|
|||||||
Generated
+460
-1687
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "trek-client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
"@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",
|
||||||
"marked": "^18.0.0",
|
"marked": "^18.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
+10
-3
@@ -4,6 +4,8 @@ import { useAuthStore } from './store/authStore'
|
|||||||
import { useSettingsStore } from './store/settingsStore'
|
import { useSettingsStore } from './store/settingsStore'
|
||||||
import { useAddonStore } from './store/addonStore'
|
import { useAddonStore } from './store/addonStore'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||||
|
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import TripPlannerPage from './pages/TripPlannerPage'
|
import TripPlannerPage from './pages/TripPlannerPage'
|
||||||
import FilesPage from './pages/FilesPage'
|
import FilesPage from './pages/FilesPage'
|
||||||
@@ -56,7 +58,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
const redirectParam = encodeURIComponent(location.pathname + location.search)
|
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +199,10 @@ export default function App() {
|
|||||||
applyDark(mode === true || mode === 'dark')
|
applyDark(mode === true || mode === 'dark')
|
||||||
}, [settings.dark_mode, isSharedPage])
|
}, [settings.dark_mode, isSharedPage])
|
||||||
|
|
||||||
const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register')
|
const isAuthPage = location.pathname.startsWith('/login')
|
||||||
|
|| location.pathname.startsWith('/register')
|
||||||
|
|| location.pathname.startsWith('/forgot-password')
|
||||||
|
|| location.pathname.startsWith('/reset-password')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TranslationProvider>
|
<TranslationProvider>
|
||||||
@@ -210,8 +215,10 @@ export default function App() {
|
|||||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||||
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||||
<Route path="/register" element={<LoginPage />} />
|
<Route path="/register" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
|
<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/authorize" element={<OAuthAuthorizePage />} />
|
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+127
-61
@@ -1,5 +1,6 @@
|
|||||||
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'
|
||||||
@@ -33,6 +34,7 @@ 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',
|
||||||
},
|
},
|
||||||
@@ -42,55 +44,110 @@ 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)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
export function isAuthPublicPath(pathname: string): boolean {
|
||||||
|
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) => {
|
||||||
(error) => {
|
sessionStorage.removeItem('proxy_reauth_attempted')
|
||||||
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
|
return response
|
||||||
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
|
},
|
||||||
const currentPath = window.location.pathname + window.location.search
|
async (error) => {
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||||
|
// 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
|
||||||
if (
|
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||||
error.response?.status === 403 &&
|
// always application/json, so checking for text/html is unambiguous.
|
||||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
if (error.response?.status === 401) {
|
||||||
!window.location.pathname.startsWith('/settings')
|
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||||
) {
|
if (ct.includes('text/html')) {
|
||||||
window.location.href = '/settings?mfa=required'
|
const { pathname } = window.location
|
||||||
}
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
if (error.response?.status === 429) {
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
const translated = translateRateLimit()
|
await unregisterSWAndReload()
|
||||||
const data = error.response.data as { error?: string } | undefined
|
return Promise.reject(error)
|
||||||
if (data && typeof data === 'object') {
|
}
|
||||||
data.error = translated
|
}
|
||||||
} else {
|
|
||||||
error.response.data = { error: translated }
|
|
||||||
}
|
}
|
||||||
error.message = translated
|
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)
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
@@ -114,6 +171,8 @@ export const authApi = {
|
|||||||
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
|
||||||
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
|
||||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||||
|
forgotPassword: (data: { email: string }) => apiClient.post('/auth/forgot-password', data).then(r => r.data as { ok: true }),
|
||||||
|
resetPassword: (data: { token: string; new_password: string; mfa_code?: string }) => apiClient.post('/auth/reset-password', data).then(r => r.data as { success?: true; mfa_required?: true }),
|
||||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||||
mcpTokens: {
|
mcpTokens: {
|
||||||
@@ -133,6 +192,7 @@ 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) */
|
||||||
@@ -144,12 +204,13 @@ 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),
|
||||||
},
|
},
|
||||||
@@ -206,11 +267,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 = {
|
||||||
@@ -304,7 +365,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),
|
||||||
@@ -313,7 +374,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),
|
||||||
@@ -343,12 +404,17 @@ export const journeyApi = {
|
|||||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||||
|
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||||
|
|
||||||
// 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, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
|
linkPhoto: (entryId: number, journeyPhotoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { journey_photo_id: journeyPhotoId }).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),
|
||||||
|
|
||||||
@@ -373,7 +439,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),
|
||||||
@@ -429,7 +495,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 = {
|
||||||
@@ -515,21 +581,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
|
||||||
@@ -32,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('ALL_SCOPES', () => {
|
describe('ALL_SCOPES', () => {
|
||||||
it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
|
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
|
||||||
expect(ALL_SCOPES).toHaveLength(24)
|
expect(ALL_SCOPES).toHaveLength(27)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
|||||||
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
|
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
|
||||||
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
|
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
|
||||||
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
|
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
|
||||||
|
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
|
||||||
|
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
|
||||||
|
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://ko-fi.com/mauriceboe"
|
href="https://ko-fi.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -148,7 +148,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://buymeacoffee.com/mauriceboe"
|
href="https://buymeacoffee.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -166,7 +166,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://discord.gg/NhZBDSd4qW"
|
href="https://discord.gg/NhZBDSd4qW"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -187,7 +187,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -205,7 +205,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -223,7 +223,7 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
|||||||
href="https://github.com/mauriceboe/TREK/wiki"
|
href="https://github.com/mauriceboe/TREK/wiki"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
|||||||
@@ -107,10 +107,12 @@ export default function PermissionsPanel(): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
title={t('perm.resetDefaults')}
|
||||||
|
aria-label={t('perm.resetDefaults')}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
{t('perm.resetDefaults')}
|
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
|
|||||||
@@ -529,11 +529,14 @@ function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
|
||||||
<div style={{
|
<div
|
||||||
width: size, height: size, borderRadius: '50%',
|
className="trek-pie-reveal"
|
||||||
background: `conic-gradient(${stops})`,
|
style={{
|
||||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
width: size, height: size, borderRadius: '50%',
|
||||||
}} />
|
background: `conic-gradient(${stops})`,
|
||||||
|
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: '50%', left: '50%',
|
position: 'absolute', top: '50%', left: '50%',
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
@@ -631,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[oldName] || []
|
const items = grouped.get(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 = () => {
|
||||||
@@ -716,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="hidden md:flex" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
<div className="flex flex-wrap max-md:!w-full max-md:!mt-2" style={{ alignItems: 'center', gap: 8, marginLeft: 'auto', flexShrink: 0 }}>
|
||||||
<div style={{ width: 150 }}>
|
<div className="max-md:!w-full" style={{ width: 150 }}>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={setCurrency}
|
onChange={setCurrency}
|
||||||
@@ -727,7 +730,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ display: 'flex', gap: 6, width: 260 }}>
|
<div className="max-md:!w-full" 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)}
|
||||||
@@ -760,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} /> CSV
|
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -897,29 +900,30 @@ 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, display: 'flex', alignItems: 'center', gap: 4 }}>
|
<td style={td}>
|
||||||
{canEdit && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
|
{canEdit && (
|
||||||
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
|
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
|
||||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
|
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
|
||||||
<GripVertical size={12} />
|
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||||
|
<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' }}>
|
||||||
|
|||||||
@@ -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-[96px] md:pb-3">
|
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-3">
|
||||||
{/* Reply preview */}
|
{/* Reply preview */}
|
||||||
{replyTo && (
|
{replyTo && (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -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 } from 'lucide-react'
|
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 { 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 } from '../../utils/fileDownload'
|
import { downloadFile, openFile as openFileUrl } 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={() => openFile(file.url).catch(() => {})}
|
onClick={() => openFileUrl(file.url, file.original_name).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,6 +236,15 @@ 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>
|
||||||
@@ -490,7 +499,9 @@ 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 => (
|
||||||
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
TRANSPORT_TYPES.has(r.type)
|
||||||
|
? <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'} />
|
||||||
@@ -649,8 +660,17 @@ 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={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||||
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
|
<span>{day.title || t('dayplan.dayN', { n: day.day_number })}</span>
|
||||||
|
{(() => {
|
||||||
|
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>
|
||||||
@@ -664,52 +684,68 @@ 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 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
{bookingReservations.length > 0 && (
|
||||||
{t('files.assignBooking')}
|
<>
|
||||||
</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
{reservations.map(r => {
|
{t('files.assignBooking')}
|
||||||
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
</div>
|
||||||
return (
|
{bookingReservations.map(reservationBtn)}
|
||||||
<button key={r.id} onClick={async () => {
|
</>
|
||||||
if (isLinked) {
|
)}
|
||||||
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
{transportReservations.length > 0 && (
|
||||||
if (file.reservation_id === r.id) {
|
<>
|
||||||
await handleAssign(file.id, { reservation_id: null })
|
<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 }}>
|
||||||
} else {
|
{t('files.assignTransport')}
|
||||||
try {
|
</div>
|
||||||
const linksRes = await filesApi.getLinks(tripId, file.id)
|
{transportReservations.map(reservationBtn)}
|
||||||
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>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -743,7 +779,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={() => openFile(previewFile.url).catch(() => {})}
|
onClick={() => openFileUrl(previewFile.url, previewFile.original_name).catch(() => toast.error(t('files.openError')))}
|
||||||
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)'}>
|
||||||
@@ -771,7 +807,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={() => openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
|
<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>
|
||||||
</p>
|
</p>
|
||||||
</object>
|
</object>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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 {
|
||||||
@@ -24,6 +26,8 @@ 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 {
|
||||||
@@ -49,6 +53,8 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,30 +65,19 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
|||||||
const MARKER_W = 28
|
const MARKER_W = 28
|
||||||
const MARKER_H = 36
|
const MARKER_H = 36
|
||||||
|
|
||||||
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
|
function markerSvg(dayColor: string, dayLabel: number, highlighted: boolean): string {
|
||||||
// Highlighted: inverted colors for contrast (black on light, white on dark)
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
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
|
||||||
? (dark
|
? 'filter:drop-shadow(0 0 10px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||||
? '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(index + 1)
|
const label = String(dayLabel)
|
||||||
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="${fill}" stroke="${stroke}" stroke-width="2"/>
|
<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"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${dayColor}"/>
|
||||||
<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="#fff" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>`
|
</div>`
|
||||||
}
|
}
|
||||||
@@ -115,12 +110,11 @@ 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(idx, false, isDark),
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
}))
|
}))
|
||||||
marker.setZIndexOffset(0)
|
marker.setZIndexOffset(0)
|
||||||
}
|
}
|
||||||
@@ -130,12 +124,11 @@ 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(idx, true, isDark),
|
html: markerSvg(item.dayColor, item.dayLabel, true),
|
||||||
}))
|
}))
|
||||||
marker.setZIndexOffset(1000)
|
marker.setZIndexOffset(1000)
|
||||||
}
|
}
|
||||||
@@ -183,6 +176,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||||
|
// Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle
|
||||||
|
// before loading tiles). On the journey mobile combined view we flyTo
|
||||||
|
// constantly when switching cards, so tiles lag visibly — force eager
|
||||||
|
// updates and keep a larger ring of off-screen tiles ready.
|
||||||
|
updateWhenIdle: false,
|
||||||
|
keepBuffer: 4,
|
||||||
} as any).addTo(map)
|
} as any).addTo(map)
|
||||||
|
|
||||||
const items = buildMarkerItems(entries)
|
const items = buildMarkerItems(entries)
|
||||||
@@ -220,7 +219,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(i, false, !!dark),
|
html: markerSvg(item.dayColor, item.dayLabel, false),
|
||||||
})
|
})
|
||||||
|
|
||||||
const marker = L.marker(pos, { icon }).addTo(map)
|
const marker = L.marker(pos, { icon }).addTo(map)
|
||||||
@@ -244,7 +243,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
map.invalidateSize()
|
map.invalidateSize()
|
||||||
if (allCoords.length > 0) {
|
if (allCoords.length > 0) {
|
||||||
const pb = paddingBottom || 50
|
const pb = paddingBottom || 50
|
||||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 })
|
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||||||
} else {
|
} else {
|
||||||
map.setView([30, 0], 2)
|
map.setView([30, 0], 2)
|
||||||
}
|
}
|
||||||
@@ -269,8 +268,14 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
highlightMarker(activeMarkerId)
|
highlightMarker(activeMarkerId)
|
||||||
const marker = markersRef.current.get(activeMarkerId)
|
const marker = markersRef.current.get(activeMarkerId)
|
||||||
if (marker && mapRef.current) {
|
if (!marker || !mapRef.current) return
|
||||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
// fitBounds may still be pending when this fires — getZoom() throws
|
||||||
|
// "Set map center and zoom first" until the map has a view. Guard it.
|
||||||
|
try {
|
||||||
|
const currentZoom = mapRef.current.getZoom()
|
||||||
|
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
|
||||||
|
} catch {
|
||||||
|
mapRef.current.setView(marker.getLatLng(), 12)
|
||||||
}
|
}
|
||||||
}, 50)
|
}, 50)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
|
||||||
|
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
|
||||||
|
|
||||||
|
// Unified handle — both providers expose the same three methods.
|
||||||
|
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||||
|
|
||||||
|
interface MapEntry {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
title?: string | null
|
||||||
|
location_name?: string | null
|
||||||
|
mood?: string | null
|
||||||
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checkins: unknown[]
|
||||||
|
entries: MapEntry[]
|
||||||
|
trail?: { lat: number; lng: number }[]
|
||||||
|
height?: number
|
||||||
|
dark?: boolean
|
||||||
|
activeMarkerId?: string | null
|
||||||
|
onMarkerClick?: (id: string, type?: string) => void
|
||||||
|
fullScreen?: boolean
|
||||||
|
paddingBottom?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyMapAuto(props, ref) {
|
||||||
|
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||||
|
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||||
|
const leafletRef = useRef<JourneyMapHandle>(null)
|
||||||
|
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||||
|
|
||||||
|
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||||
|
// supplied a token yet — otherwise the map would just show a stub.
|
||||||
|
const useGL = provider === 'mapbox-gl' && !!token
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||||
|
focusMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.focusMarker(id),
|
||||||
|
invalidateSize: () => (useGL ? glRef.current : leafletRef.current)?.invalidateSize(),
|
||||||
|
}), [useGL])
|
||||||
|
|
||||||
|
if (useGL) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return <JourneyMapGL ref={glRef} {...(props as any)} />
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return <JourneyMap ref={leafletRef} {...(props as any)} />
|
||||||
|
})
|
||||||
|
|
||||||
|
export default JourneyMapAuto
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||||
|
import mapboxgl from 'mapbox-gl'
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||||
|
|
||||||
|
export interface JourneyMapGLHandle {
|
||||||
|
highlightMarker: (id: string | null) => void
|
||||||
|
focusMarker: (id: string) => void
|
||||||
|
invalidateSize: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapEntry {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
title?: string | null
|
||||||
|
location_name?: string | null
|
||||||
|
mood?: string | null
|
||||||
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checkins: unknown[]
|
||||||
|
entries: MapEntry[]
|
||||||
|
trail?: { lat: number; lng: number }[]
|
||||||
|
height?: number
|
||||||
|
dark?: boolean
|
||||||
|
activeMarkerId?: string | null
|
||||||
|
onMarkerClick?: (id: string, type?: string) => void
|
||||||
|
fullScreen?: boolean
|
||||||
|
paddingBottom?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
id: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
label: string
|
||||||
|
locationName: string
|
||||||
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_W = 28
|
||||||
|
const MARKER_H = 36
|
||||||
|
|
||||||
|
function buildItems(entries: MapEntry[]): Item[] {
|
||||||
|
const items: Item[] = []
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.lat && e.lng) {
|
||||||
|
items.push({
|
||||||
|
id: e.id,
|
||||||
|
lat: e.lat,
|
||||||
|
lng: e.lng,
|
||||||
|
label: e.title || '',
|
||||||
|
locationName: e.location_name || '',
|
||||||
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort((a, b) => a.time.localeCompare(b.time))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntryDate(iso: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
try {
|
||||||
|
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
|
||||||
|
if (Number.isNaN(d.getTime())) return iso
|
||||||
|
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the popup styles once per document. Two-line frosted-glass card in
|
||||||
|
// the Apple/Google Maps idiom — title on top, location / date subtly below.
|
||||||
|
function ensureJourneyPopupStyle() {
|
||||||
|
if (document.getElementById('trek-journey-popup-style')) return
|
||||||
|
const s = document.createElement('style')
|
||||||
|
s.id = 'trek-journey-popup-style'
|
||||||
|
s.textContent = `
|
||||||
|
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||||
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
|
||||||
|
padding: 9px 14px 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
|
font-family: -apple-system, system-ui, sans-serif;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
|
||||||
|
background: rgba(24, 24, 27, 0.88);
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #FAFAFA;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.94);
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
|
||||||
|
border-top-color: rgba(24, 24, 27, 0.88);
|
||||||
|
border-bottom-color: rgba(24, 24, 27, 0.88);
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
||||||
|
.trek-journey-popup-title {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: #18181B;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||||
|
.trek-journey-popup-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 3px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #71717A;
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||||
|
.trek-journey-popup-place {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.trek-journey-popup-sep {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: 0.55;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.trek-journey-popup-date { flex: 0 0 auto; }
|
||||||
|
@keyframes trek-journey-popup-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||||
|
const fill = dayColor
|
||||||
|
const textColor = '#fff'
|
||||||
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
|
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))'
|
||||||
|
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
|
const scale = highlighted ? 1.2 : 1
|
||||||
|
const label = String(dayLabel)
|
||||||
|
|
||||||
|
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||||
|
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||||
|
// the CSS transition would catch the map's per-frame translate updates and
|
||||||
|
// the marker smears all over the viewport while scrolling / flying.
|
||||||
|
const wrap = document.createElement('div')
|
||||||
|
wrap.style.cssText = `width:${MARKER_W}px;height:${MARKER_H}px;cursor:pointer;`
|
||||||
|
const inner = document.createElement('div')
|
||||||
|
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.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"/>
|
||||||
|
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||||
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
|
</svg>`
|
||||||
|
wrap.appendChild(inner)
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||||
|
|
||||||
|
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
||||||
|
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const stableTrail = trail || EMPTY_TRAIL
|
||||||
|
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
|
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||||
|
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||||
|
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
|
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
||||||
|
const itemsRef = useRef<Item[]>([])
|
||||||
|
const highlightedRef = useRef<string | null>(null)
|
||||||
|
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||||
|
const onMarkerClickRef = useRef(onMarkerClick)
|
||||||
|
onMarkerClickRef.current = onMarkerClick
|
||||||
|
const darkRef = useRef(dark)
|
||||||
|
darkRef.current = dark
|
||||||
|
|
||||||
|
const showPopup = useCallback((id: string) => {
|
||||||
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
|
if (!item || !mapRef.current) return
|
||||||
|
ensureJourneyPopupStyle()
|
||||||
|
// Primary line: user-given title. If none, fall back to the location
|
||||||
|
// name so we always show *something* useful on the top line.
|
||||||
|
const primaryRaw = item.label || item.locationName || 'Entry'
|
||||||
|
const secondaryPlace = item.label ? item.locationName : ''
|
||||||
|
const dateStr = formatEntryDate(item.time)
|
||||||
|
const primary = escapeHtml(primaryRaw)
|
||||||
|
const place = escapeHtml(secondaryPlace)
|
||||||
|
const date = escapeHtml(dateStr)
|
||||||
|
|
||||||
|
const subParts: string[] = []
|
||||||
|
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`)
|
||||||
|
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`)
|
||||||
|
const subline = subParts.length === 2
|
||||||
|
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
|
||||||
|
: subParts.join('')
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="trek-journey-popup-title">${primary}</div>
|
||||||
|
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
|
||||||
|
`
|
||||||
|
// Marker is bottom-anchored with a visible height of 36px (1.2× on
|
||||||
|
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
|
||||||
|
const offset: [number, number] = [0, -46]
|
||||||
|
if (popupRef.current) {
|
||||||
|
popupRef.current.setLngLat([item.lng, item.lat])
|
||||||
|
popupRef.current.setHTML(html)
|
||||||
|
popupRef.current.setOffset(offset)
|
||||||
|
const el = popupRef.current.getElement()
|
||||||
|
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||||||
|
} else {
|
||||||
|
popupRef.current = new mapboxgl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
closeOnMove: false,
|
||||||
|
anchor: 'bottom',
|
||||||
|
offset,
|
||||||
|
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
|
||||||
|
maxWidth: '280px',
|
||||||
|
})
|
||||||
|
.setLngLat([item.lng, item.lat])
|
||||||
|
.setHTML(html)
|
||||||
|
.addTo(mapRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hidePopup = useCallback(() => {
|
||||||
|
if (popupRef.current) {
|
||||||
|
try { popupRef.current.remove() } catch { /* noop */ }
|
||||||
|
popupRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setMarkerStyle = useCallback((id: string, highlighted: boolean) => {
|
||||||
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
|
const marker = markersRef.current.get(id)
|
||||||
|
if (!item || !marker) return
|
||||||
|
const el = marker.getElement()
|
||||||
|
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||||
|
if (!currentInner) return
|
||||||
|
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||||
|
// would wipe mapbox's positional transform and make the marker flicker.
|
||||||
|
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
|
||||||
|
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||||
|
currentInner.style.cssText = nextInner.style.cssText
|
||||||
|
currentInner.innerHTML = nextInner.innerHTML
|
||||||
|
el.style.zIndex = highlighted ? '1000' : '0'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const highlightMarker = useCallback((id: string | null) => {
|
||||||
|
const prev = highlightedRef.current
|
||||||
|
highlightedRef.current = id
|
||||||
|
if (prev && prev !== id) setMarkerStyle(prev, false)
|
||||||
|
if (id) {
|
||||||
|
setMarkerStyle(id, true)
|
||||||
|
showPopup(id)
|
||||||
|
} else {
|
||||||
|
hidePopup()
|
||||||
|
}
|
||||||
|
}, [setMarkerStyle, showPopup, hidePopup])
|
||||||
|
|
||||||
|
const focusMarker = useCallback((id: string) => {
|
||||||
|
highlightMarker(id)
|
||||||
|
const marker = markersRef.current.get(id)
|
||||||
|
if (!marker || !mapRef.current) return
|
||||||
|
try {
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: marker.getLngLat(),
|
||||||
|
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||||
|
pitch: mapbox3d ? 45 : 0,
|
||||||
|
duration: 600,
|
||||||
|
})
|
||||||
|
} catch { /* map not yet ready */ }
|
||||||
|
}, [highlightMarker, mapbox3d])
|
||||||
|
|
||||||
|
const invalidateSize = useCallback(() => {
|
||||||
|
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [highlightMarker, focusMarker, invalidateSize])
|
||||||
|
|
||||||
|
// Build map once per style/token change. Markers and layers are rebuilt
|
||||||
|
// inside the same effect so they stay in sync with the active style.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !mapboxToken) return
|
||||||
|
mapboxgl.accessToken = mapboxToken
|
||||||
|
|
||||||
|
const items = buildItems(entries)
|
||||||
|
itemsRef.current = items
|
||||||
|
|
||||||
|
const bounds = new mapboxgl.LngLatBounds()
|
||||||
|
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
||||||
|
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||||
|
const hasPoints = items.length > 0 || stableTrail.length > 0
|
||||||
|
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style: mapboxStyle,
|
||||||
|
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||||
|
zoom: hasPoints ? 2 : 1,
|
||||||
|
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||||
|
attributionControl: true,
|
||||||
|
antialias: mapboxQuality,
|
||||||
|
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
if (mapbox3d) {
|
||||||
|
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||||
|
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||||
|
}
|
||||||
|
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
||||||
|
// stay pinned to their coordinates at every zoom and pitch.
|
||||||
|
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||||
|
try { map.setTerrain(null) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// route trail — dashed line connecting entries in time order
|
||||||
|
if (items.length > 1) {
|
||||||
|
const coords = items.map(i => [i.lng, i.lat])
|
||||||
|
if (map.getSource('journey-route')) (map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({
|
||||||
|
type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
|
||||||
|
})
|
||||||
|
else {
|
||||||
|
map.addSource('journey-route', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString },
|
||||||
|
})
|
||||||
|
map.addLayer({
|
||||||
|
id: 'journey-route-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'journey-route',
|
||||||
|
paint: {
|
||||||
|
'line-color': darkRef.current ? '#71717A' : '#A1A1AA',
|
||||||
|
'line-width': 1.5,
|
||||||
|
'line-opacity': 0.5,
|
||||||
|
'line-dasharray': [2, 3],
|
||||||
|
},
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markers
|
||||||
|
items.forEach((item) => {
|
||||||
|
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||||
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||||
|
.setLngLat([item.lng, item.lat])
|
||||||
|
.addTo(map)
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onMarkerClickRef.current?.(item.id)
|
||||||
|
})
|
||||||
|
markersRef.current.set(item.id, marker)
|
||||||
|
})
|
||||||
|
|
||||||
|
// fit bounds to all points
|
||||||
|
if (hasPoints) {
|
||||||
|
const pb = paddingBottom || 50
|
||||||
|
try {
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
||||||
|
maxZoom: 16,
|
||||||
|
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
} catch { /* empty bounds */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
markersRef.current.forEach(m => m.remove())
|
||||||
|
markersRef.current.clear()
|
||||||
|
if (popupRef.current) {
|
||||||
|
try { popupRef.current.remove() } catch { /* noop */ }
|
||||||
|
popupRef.current = null
|
||||||
|
}
|
||||||
|
highlightedRef.current = null
|
||||||
|
try { map.remove() } catch { /* noop */ }
|
||||||
|
mapRef.current = null
|
||||||
|
}
|
||||||
|
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||||
|
|
||||||
|
// external activeMarkerId → highlight + flyTo
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeMarkerId || !mapRef.current) return
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
highlightMarker(activeMarkerId)
|
||||||
|
const marker = markersRef.current.get(activeMarkerId)
|
||||||
|
if (!marker || !mapRef.current) return
|
||||||
|
try {
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: marker.getLngLat(),
|
||||||
|
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||||
|
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||||
|
duration: 500,
|
||||||
|
})
|
||||||
|
} catch { /* map not ready */ }
|
||||||
|
}, 50)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
||||||
|
|
||||||
|
if (!mapboxToken) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
||||||
|
className="flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6"
|
||||||
|
>
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
No Mapbox access token configured.<br />
|
||||||
|
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||||
|
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default JourneyMapGL
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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> = {
|
||||||
@@ -37,13 +38,14 @@ 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 }
|
||||||
index: number
|
dayLabel: number
|
||||||
|
dayColor: string
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
publicPhotoUrl?: (photoId: number) => string
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileEntryCard({ entry, index, isActive, onClick, publicPhotoUrl }: Props) {
|
export default function MobileEntryCard({ entry, dayLabel, dayColor, 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
|
||||||
@@ -98,8 +100,8 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
|||||||
<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 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] font-bold flex items-center justify-center flex-shrink-0">
|
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
|
||||||
{index + 1}
|
{dayLabel}
|
||||||
</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 && (
|
||||||
@@ -141,7 +143,7 @@ export default function MobileEntryCard({ entry, index, isActive, onClick, publi
|
|||||||
{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">{entry.location_name || 'On the map'}</span>
|
<span className="truncate">{formatLocationName(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,6 +6,7 @@ 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 }> = {
|
||||||
@@ -24,19 +25,22 @@ 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'): string {
|
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'original', builder?: (id: number) => string): 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
|
||||||
|
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, onClose, onEdit, onDelete, onPhotoClick }: Props) {
|
export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, 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
|
||||||
@@ -57,21 +61,23 @@ export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPh
|
|||||||
>
|
>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1.5">
|
{!readOnly && (
|
||||||
<button
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={() => { onClose(); onEdit(); }}
|
<button
|
||||||
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
onClick={() => { onClose(); onEdit(); }}
|
||||||
>
|
className="h-8 px-3 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 text-[12px] font-medium flex items-center gap-1.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors"
|
||||||
<Pencil size={13} />
|
>
|
||||||
Edit
|
<Pencil size={13} />
|
||||||
</button>
|
Edit
|
||||||
<button
|
</button>
|
||||||
onClick={() => { onClose(); onDelete(); }}
|
<button
|
||||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
onClick={() => { onClose(); onDelete(); }}
|
||||||
>
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20 dark:hover:text-red-400 transition-colors"
|
||||||
<Trash2 size={15} />
|
>
|
||||||
</button>
|
<Trash2 size={15} />
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
@@ -81,7 +87,7 @@ export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPh
|
|||||||
{photos.length > 0 && (
|
{photos.length > 0 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
src={photoUrl(photos[0])}
|
src={photoUrl(photos[0], 'original', publicPhotoUrl)}
|
||||||
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)}
|
||||||
@@ -98,7 +104,7 @@ export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPh
|
|||||||
{photos.map((p, i) => (
|
{photos.map((p, i) => (
|
||||||
<img
|
<img
|
||||||
key={p.id || i}
|
key={p.id || i}
|
||||||
src={photoUrl(p, 'thumbnail')}
|
src={photoUrl(p, 'thumbnail', publicPhotoUrl)}
|
||||||
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)}
|
||||||
@@ -127,7 +133,7 @@ export default function MobileEntryView({ entry, onClose, onEdit, onDelete, onPh
|
|||||||
<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" />
|
||||||
{entry.location_name}
|
{formatLocationName(entry.location_name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
import { useRef, useState, useEffect, useCallback, useMemo } 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
|
||||||
@@ -23,6 +24,7 @@ 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({
|
||||||
@@ -34,12 +36,23 @@ 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 cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
|
|
||||||
|
|
||||||
|
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())
|
||||||
// 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]
|
||||||
@@ -53,41 +66,68 @@ export default function MobileMapTimeline({
|
|||||||
}
|
}
|
||||||
}, [entries, mapEntries])
|
}, [entries, mapEntries])
|
||||||
|
|
||||||
// IntersectionObserver for instant snap detection
|
// Pick the card that's currently closest to the carousel horizontal center.
|
||||||
|
// More stable than IntersectionObserver thresholds when the active card can
|
||||||
|
// drift toward the viewport edge with proximity snapping.
|
||||||
|
const pickNearestCard = useCallback(() => {
|
||||||
|
const el = carouselRef.current
|
||||||
|
if (!el) return
|
||||||
|
const containerCenter = el.getBoundingClientRect().left + el.clientWidth / 2
|
||||||
|
let bestIdx = 0
|
||||||
|
let bestDist = Infinity
|
||||||
|
cardRefs.current.forEach((node, idx) => {
|
||||||
|
const r = node.getBoundingClientRect()
|
||||||
|
const cardCenter = r.left + r.width / 2
|
||||||
|
const d = Math.abs(cardCenter - containerCenter)
|
||||||
|
if (d < bestDist) { bestDist = d; bestIdx = idx }
|
||||||
|
})
|
||||||
|
setActiveIndex(prev => {
|
||||||
|
if (prev !== bestIdx) syncMapToCarousel(bestIdx)
|
||||||
|
return bestIdx
|
||||||
|
})
|
||||||
|
}, [syncMapToCarousel])
|
||||||
|
|
||||||
|
// Defer all state updates until scrolling settles — updating activeIndex
|
||||||
|
// 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 settleTimer: number | null = null
|
||||||
|
const onScroll = () => {
|
||||||
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
|
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||||
|
}
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', onScroll)
|
||||||
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
|
}
|
||||||
|
}, [entries.length, pickNearestCard])
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
// Scroll a given card into the horizontal center of the carousel
|
||||||
(observed) => {
|
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||||
for (const o of observed) {
|
const card = cardRefs.current.get(idx)
|
||||||
if (o.isIntersecting) {
|
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||||
const idx = Number(o.target.getAttribute('data-idx'))
|
}, [])
|
||||||
if (!isNaN(idx)) {
|
|
||||||
setActiveIndex(idx)
|
|
||||||
syncMapToCarousel(idx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ root: el, threshold: 0.6 },
|
|
||||||
)
|
|
||||||
|
|
||||||
cardRefs.current.forEach(node => observer.observe(node))
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [entries.length, syncMapToCarousel])
|
|
||||||
|
|
||||||
// Scroll carousel to entry when map marker is clicked
|
// Scroll carousel to entry when map marker is clicked
|
||||||
const handleMarkerClick = useCallback((id: string) => {
|
const handleMarkerClick = useCallback((id: string) => {
|
||||||
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
const idx = entries.findIndex((e: any) => String(e.id) === id)
|
||||||
if (idx === -1) return
|
if (idx === -1) return
|
||||||
setActiveIndex(idx)
|
setActiveIndex(idx)
|
||||||
|
scrollCardIntoCenter(idx)
|
||||||
|
}, [entries, scrollCardIntoCenter])
|
||||||
|
|
||||||
const el = carouselRef.current
|
// Tap on a card: if it's already active, open the edit view; otherwise
|
||||||
if (!el) return
|
// activate + center it first (don't jump straight into the editor).
|
||||||
const cardWidth = 272
|
const handleCardTap = useCallback((entry: any, idx: number) => {
|
||||||
el.scrollTo({ left: idx * cardWidth, behavior: 'smooth' })
|
if (idx === activeIndex) {
|
||||||
}, [entries])
|
onEntryClick(entry)
|
||||||
|
} else {
|
||||||
|
setActiveIndex(idx)
|
||||||
|
scrollCardIntoCenter(idx)
|
||||||
|
}
|
||||||
|
}, [activeIndex, onEntryClick, scrollCardIntoCenter])
|
||||||
|
|
||||||
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
// Initial map focus — delay to let Leaflet initialize and fitBounds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,7 +143,10 @@ export default function MobileMapTimeline({
|
|||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
<div
|
||||||
|
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}
|
||||||
@@ -115,12 +158,12 @@ export default function MobileMapTimeline({
|
|||||||
fullScreen
|
fullScreen
|
||||||
/>
|
/>
|
||||||
{!readOnly && onAddEntry && (
|
{!readOnly && onAddEntry && (
|
||||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
<div className="fixed right-4 z-30" style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 16px)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={onAddEntry}
|
onClick={onAddEntry}
|
||||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
<Plus size={18} />
|
<Plus size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -129,7 +172,10 @@ export default function MobileMapTimeline({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-10" style={{ top: 0, bottom: 0 }}>
|
<div
|
||||||
|
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}
|
||||||
@@ -146,12 +192,12 @@ export default function MobileMapTimeline({
|
|||||||
|
|
||||||
{/* Bottom carousel */}
|
{/* Bottom carousel */}
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-20 left-0 right-0 z-40"
|
className="fixed left-0 right-0 z-40"
|
||||||
style={{ touchAction: 'pan-x' }}
|
style={{ touchAction: 'pan-x', bottom: carouselBottom }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={carouselRef}
|
ref={carouselRef}
|
||||||
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1 scroll-smooth"
|
className="flex gap-3 overflow-x-auto px-4 pb-3 pt-1"
|
||||||
style={{
|
style={{
|
||||||
scrollSnapType: 'x mandatory',
|
scrollSnapType: 'x mandatory',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
@@ -168,9 +214,10 @@ export default function MobileMapTimeline({
|
|||||||
>
|
>
|
||||||
<MobileEntryCard
|
<MobileEntryCard
|
||||||
entry={entry}
|
entry={entry}
|
||||||
index={i}
|
dayLabel={entryDayMeta[i]?.dayLabel ?? i + 1}
|
||||||
|
dayColor={entryDayMeta[i]?.dayColor ?? DAY_COLORS[0]}
|
||||||
isActive={i === activeIndex}
|
isActive={i === activeIndex}
|
||||||
onClick={() => onEntryClick(entry)}
|
onClick={() => handleCardTap(entry, i)}
|
||||||
publicPhotoUrl={publicPhotoUrl}
|
publicPhotoUrl={publicPhotoUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,14 +225,17 @@ export default function MobileMapTimeline({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAB: add entry — top right */}
|
{/* FAB: add entry — bottom right, above the timeline carousel */}
|
||||||
{!readOnly && onAddEntry && (
|
{!readOnly && onAddEntry && (
|
||||||
<div className="fixed top-[calc(var(--nav-h,56px)+12px)] right-4 z-30">
|
<div
|
||||||
|
className="fixed right-4 z-30"
|
||||||
|
style={{ bottom: 'calc(var(--bottom-nav-h, 84px) + 168px)' }}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onAddEntry}
|
onClick={onAddEntry}
|
||||||
className="w-10 h-10 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
className="w-12 h-12 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 shadow-lg flex items-center justify-center hover:scale-105 active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
<Plus size={18} />
|
<Plus size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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,8 +19,10 @@ 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 } from '../../../tests/helpers/factories';
|
import { buildUser, buildSettings } 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' });
|
||||||
@@ -39,7 +41,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('Trips')).toBeInTheDocument();
|
expect(screen.getByText('My Trips')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
|
||||||
@@ -99,4 +101,39 @@ 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,10 @@ 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 BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
|
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
|
||||||
{ to: '/trips', label: 'Trips', icon: Plane },
|
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
|
||||||
]
|
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() {
|
||||||
@@ -25,11 +21,13 @@ 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 = [...BASE_ITEMS]
|
const items: { to: string; label: string; icon: LucideIcon }[] = [
|
||||||
for (const addon of globalAddons) {
|
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
|
||||||
const nav = ADDON_NAV[addon.id]
|
...globalAddons.flatMap(addon => {
|
||||||
if (nav) items.push(nav)
|
const nav = ADDON_NAV[addon.id]
|
||||||
}
|
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -266,17 +266,22 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed', inset: 0, zIndex: 9999,
|
position: 'fixed', inset: 0, zIndex: 99999,
|
||||||
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',
|
||||||
padding: 16, overflow: 'auto',
|
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||||
|
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 20px',
|
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||||
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: '90vh', overflow: 'auto',
|
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||||
|
overflow: 'auto',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -367,8 +372,10 @@ export default function DemoBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div style={{
|
<div style={{
|
||||||
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
padding: '14px 0 20px', 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} />
|
||||||
|
|||||||
@@ -34,9 +34,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||||
|
const [scrolled, setScrolled] = useState<boolean>(false)
|
||||||
const darkMode = settings.dark_mode
|
const darkMode = settings.dark_mode
|
||||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > 8 || (document.body.scrollTop || 0) > 8)
|
||||||
|
onScroll()
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
document.body.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll)
|
||||||
|
document.body.removeEventListener('scroll', onScroll)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
// Only show 'global' type addons in the navbar — 'integration' addons have no dedicated page
|
||||||
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
const globalAddons = allAddons.filter((a: Addon) => a.type === 'global' && a.enabled)
|
||||||
|
|
||||||
@@ -49,8 +61,26 @@ 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')
|
||||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||||
|
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
|
||||||
|
themeTransitionTimer.current = window.setTimeout(() => {
|
||||||
|
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||||
|
themeTransitionTimer.current = null
|
||||||
|
}, 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAddonName = (addon: Addon): string => {
|
const getAddonName = (addon: Addon): string => {
|
||||||
@@ -61,23 +91,29 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav style={{
|
<nav style={{
|
||||||
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
|
background: dark
|
||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
? (scrolled ? 'rgba(9,9,11,0.78)' : 'rgba(9,9,11,0.95)')
|
||||||
|
: (scrolled ? 'rgba(255,255,255,0.72)' : 'rgba(255,255,255,0.95)'),
|
||||||
|
backdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
|
||||||
|
WebkitBackdropFilter: scrolled ? 'blur(28px) saturate(180%)' : 'blur(20px)',
|
||||||
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
||||||
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
boxShadow: scrolled
|
||||||
|
? (dark ? '0 4px 24px rgba(0,0,0,0.35)' : '0 4px 24px rgba(0,0,0,0.08)')
|
||||||
|
: (dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)'),
|
||||||
touchAction: 'manipulation',
|
touchAction: 'manipulation',
|
||||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
height: 'var(--nav-h)',
|
height: 'var(--nav-h)',
|
||||||
|
transition: 'background 240ms cubic-bezier(0.23,1,0.32,1), backdrop-filter 240ms cubic-bezier(0.23,1,0.32,1), box-shadow 240ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||||
{/* Left side */}
|
{/* Left side */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{showBack && (
|
{showBack && (
|
||||||
<button onClick={onBack}
|
<button onClick={onBack}
|
||||||
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
className="trek-back-btn p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
|
||||||
style={{ color: 'var(--text-muted)' }}
|
style={{ color: 'var(--text-muted)' }}
|
||||||
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'}>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="trek-back-icon w-4 h-4" />
|
||||||
<span className="hidden sm:inline">{t('common.back')}</span>
|
<span className="hidden sm:inline">{t('common.back')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -161,11 +197,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
|
|
||||||
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
|
||||||
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||||
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
|
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex relative w-8 h-8 items-center justify-center"
|
||||||
style={{ color: 'var(--text-muted)' }}
|
style={{ color: 'var(--text-muted)' }}
|
||||||
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'}>
|
||||||
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
<Sun className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
|
style={{ opacity: dark ? 1 : 0, transform: dark ? 'rotate(0deg) scale(1)' : 'rotate(-90deg) scale(0.6)' }} />
|
||||||
|
<Moon className="w-4 h-4 absolute transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
|
style={{ opacity: dark ? 0 : 1, transform: dark ? 'rotate(90deg) scale(0.6)' : 'rotate(0deg) scale(1)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
{/* Notification bell — only in trip view on mobile, everywhere on desktop */}
|
||||||
@@ -196,7 +235,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
{userMenuOpen && ReactDOM.createPortal(
|
{userMenuOpen && ReactDOM.createPortal(
|
||||||
<>
|
<>
|
||||||
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||||
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
<div className="trek-menu-enter w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* OfflineBanner — persistent top bar indicating connectivity + sync state.
|
* OfflineBanner — connectivity + sync state indicator.
|
||||||
*
|
*
|
||||||
* States:
|
* States:
|
||||||
* offline + N queued → amber bar "Offline — N changes queued"
|
* offline + N queued → amber pill "Offline · N queued"
|
||||||
* offline + 0 queued → amber bar "Offline"
|
* offline + 0 queued → amber pill "Offline"
|
||||||
* online + N pending → blue bar "Syncing N changes…"
|
* online + N pending → blue pill "Syncing N…"
|
||||||
* 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'
|
||||||
@@ -48,9 +52,9 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
|
|
||||||
const label = offline
|
const label = offline
|
||||||
? pendingCount > 0
|
? pendingCount > 0
|
||||||
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
|
? `Offline · ${pendingCount} queued`
|
||||||
: 'Offline'
|
: 'Offline'
|
||||||
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}…`
|
: `Syncing ${pendingCount}…`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,27 +62,29 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 0,
|
// Hover above the mobile bottom nav; on desktop --bottom-nav-h is 0,
|
||||||
left: 0,
|
// so the pill sits 16px from the bottom.
|
||||||
right: 0,
|
bottom: 'calc(var(--bottom-nav-h) + 16px)',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
background: bg,
|
background: bg,
|
||||||
color: text,
|
color: text,
|
||||||
display: 'flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: 6,
|
||||||
gap: 8,
|
padding: '6px 14px',
|
||||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
|
borderRadius: 999,
|
||||||
paddingBottom: '6px',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.08)',
|
||||||
paddingLeft: '16px',
|
fontSize: 12,
|
||||||
paddingRight: '16px',
|
fontWeight: 600,
|
||||||
fontSize: 13,
|
whiteSpace: 'nowrap',
|
||||||
fontWeight: 500,
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{offline
|
{offline
|
||||||
? <WifiOff size={14} />
|
? <WifiOff size={12} />
|
||||||
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
|
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
}
|
}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Menu, X, type LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
export interface PageSidebarTab {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageSidebarProps {
|
||||||
|
/** Uppercase label shown above the tab list, e.g. "SETTINGS". */
|
||||||
|
sidebarLabel: string
|
||||||
|
tabs: PageSidebarTab[]
|
||||||
|
activeTab: string
|
||||||
|
onTabChange: (id: string) => void
|
||||||
|
children: React.ReactNode
|
||||||
|
/** Small text at the very bottom of the sidebar (e.g. "v3.0 · self-hosted"). */
|
||||||
|
footer?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left-sidebar + right-panel layout used by the Settings and Admin pages.
|
||||||
|
*
|
||||||
|
* Desktop (>=1024px): sidebar is always visible at 260px; panel fills rest.
|
||||||
|
* Mobile: sidebar collapses behind a hamburger at the top of the panel; tap
|
||||||
|
* the hamburger to slide the sidebar in as an overlay, tap a tab to close.
|
||||||
|
*/
|
||||||
|
export default function PageSidebar({
|
||||||
|
sidebarLabel,
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: PageSidebarProps): React.ReactElement {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const activeLabel = tabs.find(t => t.id === activeTab)?.label ?? ''
|
||||||
|
|
||||||
|
// Close the mobile drawer on Escape or on outside click.
|
||||||
|
const drawerRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobileOpen) return
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMobileOpen(false) }
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [mobileOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl overflow-hidden flex flex-col lg:flex-row relative"
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border-primary)',
|
||||||
|
minHeight: 'min(820px, calc(100vh - var(--nav-h) - 120px))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Mobile top bar with hamburger */}
|
||||||
|
<div
|
||||||
|
className="lg:hidden flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: 'var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
className="w-9 h-9 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||||
|
aria-label="Open navigation"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<Menu size={18} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{activeLabel}
|
||||||
|
</div>
|
||||||
|
<div className="w-9" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop sidebar (always visible on lg) */}
|
||||||
|
<aside
|
||||||
|
className="hidden lg:flex flex-col shrink-0 relative"
|
||||||
|
style={{
|
||||||
|
width: 260,
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
borderRight: '1px solid var(--border-primary)',
|
||||||
|
padding: '24px 14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarInner
|
||||||
|
sidebarLabel={sidebarLabel}
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={onTabChange}
|
||||||
|
footer={footer}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile drawer */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 z-40"
|
||||||
|
style={{ background: 'rgba(0,0,0,0.35)' }}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
ref={drawerRef}
|
||||||
|
className="lg:hidden fixed top-0 left-0 bottom-0 z-50 flex flex-col shadow-2xl"
|
||||||
|
style={{
|
||||||
|
width: 280,
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
padding: '18px 14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3 px-2">
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-bold tracking-widest uppercase"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{sidebarLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors hover:bg-[var(--bg-hover)]"
|
||||||
|
aria-label="Close navigation"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SidebarInner
|
||||||
|
sidebarLabel={null}
|
||||||
|
tabs={tabs}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={(id) => {
|
||||||
|
onTabChange(id)
|
||||||
|
setMobileOpen(false)
|
||||||
|
}}
|
||||||
|
footer={footer}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="flex-1 min-w-0" style={{ padding: '26px 28px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInner({
|
||||||
|
sidebarLabel,
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
|
sidebarLabel: string | null
|
||||||
|
tabs: PageSidebarTab[]
|
||||||
|
activeTab: string
|
||||||
|
onTabChange: (id: string) => void
|
||||||
|
footer?: React.ReactNode
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sidebarLabel && (
|
||||||
|
<div
|
||||||
|
className="text-[11px] font-bold tracking-widest uppercase mb-3 px-3"
|
||||||
|
style={{ color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
{sidebarLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<nav className="flex flex-col gap-1 flex-1">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon
|
||||||
|
const active = tab.id === activeTab
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors"
|
||||||
|
style={{
|
||||||
|
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
color: active ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||||
|
fontWeight: active ? 600 : 500,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!active) e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!active) e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={16} className="shrink-0" />
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
{footer && (
|
||||||
|
<div
|
||||||
|
className="mt-4 pt-3 px-3 text-[10px] tracking-wide"
|
||||||
|
style={{ color: 'var(--text-faint)', borderTop: '1px solid var(--border-primary)' }}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||||
|
import type { TrackingMode } from '../../hooks/useGeolocation'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
mode: TrackingMode
|
||||||
|
error: string | null
|
||||||
|
onClick: () => void
|
||||||
|
// Offset from the bottom edge — callers push this up above the mobile
|
||||||
|
// bottom nav. Defaults to 20px for desktop.
|
||||||
|
bottomOffset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three-state FAB. Matches the Apple/Google Maps pattern:
|
||||||
|
// off → outline locate icon
|
||||||
|
// show → filled locate (blue dot is visible on the map)
|
||||||
|
// follow → filled navigation arrow (map follows + rotates with heading)
|
||||||
|
export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) {
|
||||||
|
const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate
|
||||||
|
const isActive = mode !== 'off'
|
||||||
|
const title = error
|
||||||
|
? error
|
||||||
|
: mode === 'off'
|
||||||
|
? 'Show my location'
|
||||||
|
: mode === 'show'
|
||||||
|
? 'Follow my location'
|
||||||
|
: 'Stop following'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: bottomOffset,
|
||||||
|
right: 12,
|
||||||
|
zIndex: 1000,
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||||
|
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'background 0.2s, color 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={20} strokeWidth={mode === 'follow' ? 2.5 : 2} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,16 @@ 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" />,
|
||||||
@@ -27,15 +37,7 @@ 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: () => ({
|
useMap: () => mapMock,
|
||||||
panTo: vi.fn(),
|
|
||||||
setView: vi.fn(),
|
|
||||||
fitBounds: vi.fn(),
|
|
||||||
getZoom: () => 10,
|
|
||||||
on: vi.fn(),
|
|
||||||
off: vi.fn(),
|
|
||||||
panBy: vi.fn(),
|
|
||||||
}),
|
|
||||||
useMapEvents: () => ({}),
|
useMapEvents: () => ({}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
resetAllStores()
|
resetAllStores()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -216,4 +219,33 @@ 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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [fitKey, places, paddingOpts, map, hasDayDetail])
|
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -233,18 +233,7 @@ interface RouteLabelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||||
const map = useMap()
|
if (!midpoint) return null
|
||||||
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',
|
||||||
@@ -278,93 +267,76 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|||||||
// Module-level photo cache shared with PlaceAvatar
|
// Module-level photo cache shared with PlaceAvatar
|
||||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||||
|
import LocationButton from './LocationButton'
|
||||||
|
|
||||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
// Live-location rendering inside the Leaflet map. Subscribes via the
|
||||||
function LocationTracker() {
|
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
|
||||||
|
// identically. Heading is shown as a rotated conic SVG when available.
|
||||||
|
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||||
|
|
||||||
|
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
|
||||||
const map = useMap()
|
const map = useMap()
|
||||||
const [position, setPosition] = useState<[number, number] | null>(null)
|
|
||||||
const [accuracy, setAccuracy] = useState(0)
|
|
||||||
const [tracking, setTracking] = useState(false)
|
|
||||||
const watchId = useRef<number | null>(null)
|
|
||||||
|
|
||||||
const startTracking = useCallback(() => {
|
// When the user is in follow mode, keep the map centred on the dot.
|
||||||
if (!('geolocation' in navigator)) return
|
// setView (no animation) is what Google Maps does during navigation —
|
||||||
setTracking(true)
|
// it feels responsive and avoids animation jitter at walking speed.
|
||||||
watchId.current = navigator.geolocation.watchPosition(
|
|
||||||
(pos) => {
|
|
||||||
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
|
|
||||||
setPosition(latlng)
|
|
||||||
setAccuracy(pos.coords.accuracy)
|
|
||||||
},
|
|
||||||
() => setTracking(false),
|
|
||||||
{ enableHighAccuracy: true, maximumAge: 5000 }
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const stopTracking = useCallback(() => {
|
|
||||||
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
|
|
||||||
watchId.current = null
|
|
||||||
setTracking(false)
|
|
||||||
setPosition(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleTracking = useCallback(() => {
|
|
||||||
if (tracking) { stopTracking() } else { startTracking() }
|
|
||||||
}, [tracking, startTracking, stopTracking])
|
|
||||||
|
|
||||||
// Center map on position when first acquired
|
|
||||||
const centered = useRef(false)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (position && !centered.current) {
|
if (mode !== 'follow' || !position) return
|
||||||
map.setView(position, 15)
|
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 }) } catch { /* noop */ }
|
||||||
centered.current = true
|
}, [position, mode, map])
|
||||||
}
|
|
||||||
}, [position, map])
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Once, when the user first acquires a fix in "show" mode, pan to it so
|
||||||
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
|
// they don't have to scroll the map. Subsequent fixes only move the dot.
|
||||||
|
const centeredRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'off') { centeredRef.current = false; return }
|
||||||
|
if (!position || centeredRef.current) return
|
||||||
|
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15)) } catch { /* noop */ }
|
||||||
|
centeredRef.current = true
|
||||||
|
}, [position, mode, map])
|
||||||
|
|
||||||
|
if (!position) return null
|
||||||
|
|
||||||
|
const headingIcon = position.heading === null || Number.isNaN(position.heading) ? null : L.divIcon({
|
||||||
|
className: '',
|
||||||
|
iconSize: [60, 60],
|
||||||
|
iconAnchor: [30, 30],
|
||||||
|
html: `<div style="
|
||||||
|
width:60px;height:60px;
|
||||||
|
transform:rotate(${position.heading}deg);transition:transform 120ms ease-out;
|
||||||
|
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
|
||||||
|
border-radius:50%;
|
||||||
|
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||||
|
mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||||
|
pointer-events:none;
|
||||||
|
"></div>`,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Location button */}
|
{position.accuracy < 500 && (
|
||||||
<div style={{
|
<Circle
|
||||||
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
|
center={[position.lat, position.lng]}
|
||||||
}}>
|
radius={position.accuracy}
|
||||||
<button onClick={toggleTracking} style={{
|
pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.12, weight: 1, opacity: 0.35 }}
|
||||||
width: 36, height: 36, borderRadius: '50%',
|
interactive={false}
|
||||||
border: 'none', cursor: 'pointer',
|
/>
|
||||||
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
|
|
||||||
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
transition: 'background 0.2s, color 0.2s',
|
|
||||||
}}>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Blue dot + accuracy circle */}
|
|
||||||
{position && (
|
|
||||||
<>
|
|
||||||
{accuracy < 500 && (
|
|
||||||
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
|
|
||||||
)}
|
|
||||||
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
{headingIcon && (
|
||||||
{/* Pulse animation CSS */}
|
<Marker
|
||||||
{position && (
|
position={[position.lat, position.lng]}
|
||||||
<style>{`
|
icon={headingIcon}
|
||||||
@keyframes location-pulse {
|
interactive={false}
|
||||||
0% { transform: scale(1); opacity: 0.6; }
|
zIndexOffset={900}
|
||||||
100% { transform: scale(2.5); opacity: 0; }
|
/>
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
)}
|
)}
|
||||||
|
<CircleMarker
|
||||||
|
center={[position.lat, position.lng]}
|
||||||
|
radius={8}
|
||||||
|
pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 3 }}
|
||||||
|
interactive={false}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -494,7 +466,11 @@ 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 = place.image_url || place.google_place_id || place.osm_id
|
const photoId =
|
||||||
|
(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)
|
||||||
}
|
}
|
||||||
@@ -561,8 +537,15 @@ export const MapView = memo(function MapView({
|
|||||||
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
||||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||||
|
|
||||||
|
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||||
|
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||||
|
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
|
const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="w-full h-full relative">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
id="trek-map"
|
id="trek-map"
|
||||||
center={center}
|
center={center}
|
||||||
@@ -586,7 +569,7 @@ export const MapView = memo(function MapView({
|
|||||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||||
<MapClickHandler onClick={onMapClick} />
|
<MapClickHandler onClick={onMapClick} />
|
||||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||||
<LocationTracker />
|
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||||
|
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
chunkedLoading
|
chunkedLoading
|
||||||
@@ -631,6 +614,13 @@ export const MapView = memo(function MapView({
|
|||||||
onEndpointClick={onReservationClick}
|
onEndpointClick={onReservationClick}
|
||||||
/>
|
/>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
{isMobile && <LocationButton
|
||||||
|
mode={trackingMode}
|
||||||
|
error={trackingError}
|
||||||
|
onClick={cycleTrackingMode}
|
||||||
|
bottomOffset={locationButtonBottom as unknown as number}
|
||||||
|
/>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{TooltipOverlay && (
|
{TooltipOverlay && (
|
||||||
<div data-testid="tooltip" style={{
|
<div data-testid="tooltip" style={{
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { MapView } from './MapView'
|
||||||
|
import { MapViewGL } from './MapViewGL'
|
||||||
|
|
||||||
|
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||||
|
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||||
|
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function MapViewAuto(props: any) {
|
||||||
|
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||||
|
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||||
|
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||||
|
// so trip planner never shows an empty map due to a missing token.
|
||||||
|
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
|
||||||
|
return <MapView {...props} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import mapboxgl from 'mapbox-gl'
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||||
|
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||||
|
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||||
|
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||||
|
import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||||
|
import LocationButton from './LocationButton'
|
||||||
|
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||||
|
import type { Place, Reservation } from '../../types'
|
||||||
|
|
||||||
|
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||||
|
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
|
try {
|
||||||
|
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteSegment {
|
||||||
|
mid: [number, number]
|
||||||
|
from: [number, number]
|
||||||
|
to: [number, number]
|
||||||
|
walkingText?: string
|
||||||
|
drivingText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
places: Place[]
|
||||||
|
dayPlaces?: Place[]
|
||||||
|
route?: [number, number][][] | null
|
||||||
|
routeSegments?: RouteSegment[]
|
||||||
|
selectedPlaceId?: number | null
|
||||||
|
onMarkerClick?: (id: number) => void
|
||||||
|
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
||||||
|
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
||||||
|
center?: [number, number]
|
||||||
|
zoom?: number
|
||||||
|
fitKey?: number | null
|
||||||
|
dayOrderMap?: Record<number, number[] | null>
|
||||||
|
leftWidth?: number
|
||||||
|
rightWidth?: number
|
||||||
|
hasInspector?: 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 {
|
||||||
|
const size = selected ? 44 : 36
|
||||||
|
const borderColor = selected ? '#111827' : 'white'
|
||||||
|
const borderWidth = selected ? 3 : 2.5
|
||||||
|
const shadow = selected
|
||||||
|
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||||
|
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||||
|
const bgColor = place.category_color || '#6b7280'
|
||||||
|
|
||||||
|
// The visual circle is `size` + 2*border on each side. To make the
|
||||||
|
// mapbox `anchor: 'center'` land on the real visual middle of the marker
|
||||||
|
// (rather than just the inner content box), the wrapper has to be the
|
||||||
|
// full outer size. If we gave the wrapper only `size`, the border would
|
||||||
|
// bleed outside it and the route lines would appear slightly off.
|
||||||
|
const outer = size + borderWidth * 2
|
||||||
|
|
||||||
|
let badgeHtml = ''
|
||||||
|
if (orderNumbers && orderNumbers.length > 0) {
|
||||||
|
const label = orderNumbers.join(' · ')
|
||||||
|
badgeHtml = `<span style="
|
||||||
|
position:absolute;bottom:-2px;right:-2px;
|
||||||
|
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
|
||||||
|
padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
|
||||||
|
background:rgba(255,255,255,0.94);
|
||||||
|
border:1.5px solid rgba(0,0,0,0.15);
|
||||||
|
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||||
|
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||||
|
box-sizing:border-box;white-space:nowrap;
|
||||||
|
">${label}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = document.createElement('div')
|
||||||
|
// Do NOT set `position: relative` here — mapbox-gl ships
|
||||||
|
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
|
||||||
|
// `position: relative` here overrides the class, turns every marker into
|
||||||
|
// a static block element, and stacks them in document order inside the
|
||||||
|
// canvas container. The result looks exactly like "markers drift as the
|
||||||
|
// map zooms" because each marker's transform is then applied relative
|
||||||
|
// to its stacked slot, not to the map viewport.
|
||||||
|
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
|
||||||
|
|
||||||
|
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'))
|
||||||
|
if (hasPhoto) {
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div style="
|
||||||
|
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
border:${borderWidth}px solid ${borderColor};
|
||||||
|
box-shadow:${shadow};
|
||||||
|
overflow:hidden;background:${bgColor};
|
||||||
|
box-sizing:content-box;
|
||||||
|
">
|
||||||
|
<img src="${photoUrl}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||||
|
</div>
|
||||||
|
${badgeHtml}
|
||||||
|
`
|
||||||
|
} else {
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div style="
|
||||||
|
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
border:${borderWidth}px solid ${borderColor};
|
||||||
|
box-shadow:${shadow};
|
||||||
|
background:${bgColor};
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
box-sizing:content-box;
|
||||||
|
">
|
||||||
|
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
|
||||||
|
</div>
|
||||||
|
${badgeHtml}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapViewGL({
|
||||||
|
places = [],
|
||||||
|
dayPlaces = [],
|
||||||
|
route = null,
|
||||||
|
selectedPlaceId = null,
|
||||||
|
onMarkerClick,
|
||||||
|
onMapClick,
|
||||||
|
onMapContextMenu = null,
|
||||||
|
center = [48.8566, 2.3522],
|
||||||
|
zoom = 10,
|
||||||
|
fitKey = 0,
|
||||||
|
dayOrderMap = {},
|
||||||
|
leftWidth = 0,
|
||||||
|
rightWidth = 0,
|
||||||
|
hasInspector = false,
|
||||||
|
hasDayDetail = false,
|
||||||
|
reservations = [],
|
||||||
|
visibleConnectionIds = [],
|
||||||
|
showReservationStats = false,
|
||||||
|
onReservationClick,
|
||||||
|
}: Props) {
|
||||||
|
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
|
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||||
|
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||||
|
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 [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||||
|
const [mapReady, setMapReady] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
|
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||||
|
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 onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||||
|
onClickRefs.current.marker = onMarkerClick
|
||||||
|
onClickRefs.current.map = onMapClick
|
||||||
|
onClickRefs.current.context = onMapContextMenu
|
||||||
|
|
||||||
|
// Build/rebuild the map on style/token/3d change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !mapboxToken) return
|
||||||
|
mapboxgl.accessToken = mapboxToken
|
||||||
|
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style: mapboxStyle,
|
||||||
|
center: [center[1], center[0]],
|
||||||
|
zoom,
|
||||||
|
pitch: mapbox3d ? 45 : 0,
|
||||||
|
attributionControl: true,
|
||||||
|
antialias: mapboxQuality,
|
||||||
|
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
;(window as any).__trek_map = map
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
if (mapbox3d) {
|
||||||
|
// Terrain is only valuable on satellite styles — on clean vector
|
||||||
|
// styles it makes route lines drift off the HTML markers because
|
||||||
|
// the lines snap to DEM height while markers stay at sea level.
|
||||||
|
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||||
|
if (supportsCustom3d(mapboxStyle)) {
|
||||||
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
|
addCustom3dBuildings(map, dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapbox Standard ships its own DEM-based terrain that kicks in
|
||||||
|
// below zoom 13.7. HTML markers project at sea level, so when the
|
||||||
|
// terrain exaggeration ramps up at lower zooms the markers drift
|
||||||
|
// away from the 3D buildings and route lines they belong to. The
|
||||||
|
// non-satellite Standard style still looks great without terrain,
|
||||||
|
// so flatten it out to keep markers pinned. (Satellite variants
|
||||||
|
// are left alone — the DEM is what gives them their character.)
|
||||||
|
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||||
|
try { map.setTerrain(null) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
// initial route source — kept around so updates can setData() cheaply
|
||||||
|
if (!map.getSource('trip-route')) {
|
||||||
|
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
map.addLayer({
|
||||||
|
id: 'trip-route-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'trip-route',
|
||||||
|
paint: {
|
||||||
|
'line-color': '#111827',
|
||||||
|
'line-width': 3,
|
||||||
|
'line-opacity': 0.9,
|
||||||
|
'line-dasharray': [2, 1.5],
|
||||||
|
},
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// gpx geometries source (place.route_geometry)
|
||||||
|
if (!map.getSource('trip-gpx')) {
|
||||||
|
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
map.addLayer({
|
||||||
|
id: 'trip-gpx-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'trip-gpx',
|
||||||
|
paint: {
|
||||||
|
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
|
||||||
|
'line-width': 3.5,
|
||||||
|
'line-opacity': 0.75,
|
||||||
|
},
|
||||||
|
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) => {
|
||||||
|
const t = e.originalEvent.target as HTMLElement
|
||||||
|
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||||
|
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||||
|
})
|
||||||
|
// In the mapbox-gl map the right mouse button is reserved for the
|
||||||
|
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||||
|
// to the middle mouse button (button === 1) instead.
|
||||||
|
const canvas = map.getCanvasContainer()
|
||||||
|
const onAuxDown = (ev: MouseEvent) => {
|
||||||
|
if (ev.button !== 1) return
|
||||||
|
ev.preventDefault()
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
||||||
|
onClickRefs.current.context?.({
|
||||||
|
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
||||||
|
originalEvent: ev,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Also suppress the browser's native auxclick menu on middle-click.
|
||||||
|
const onAuxClick = (ev: MouseEvent) => {
|
||||||
|
if (ev.button === 1) ev.preventDefault()
|
||||||
|
}
|
||||||
|
canvas.addEventListener('mousedown', onAuxDown)
|
||||||
|
canvas.addEventListener('auxclick', onAuxClick)
|
||||||
|
|
||||||
|
// Drop follow mode if the user pans the map manually — matches the
|
||||||
|
// Apple Maps behaviour where the blue dot stays but the map no longer
|
||||||
|
// chases it until the user taps the button again.
|
||||||
|
map.on('dragstart', () => {
|
||||||
|
setTrackingMode(prev => prev === 'follow' ? 'show' : prev)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep HTML markers glued to the terrain / 3D ground. Mapbox projects
|
||||||
|
// HTML markers at altitude=0 (sea level) by default, so as soon as the
|
||||||
|
// style has a terrain DEM (Standard, Standard Satellite, custom terrain)
|
||||||
|
// the markers drift off the places when the camera pitches or zooms —
|
||||||
|
// the buildings rise from DEM height, the marker stays at sea level,
|
||||||
|
// and the pixel offset grows as the perspective changes.
|
||||||
|
//
|
||||||
|
// Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to
|
||||||
|
// project the marker onto the same ground the route line sits on.
|
||||||
|
// We re-apply this every render because DEM tiles stream in async.
|
||||||
|
let lastAltUpdate = 0
|
||||||
|
const syncMarkerAltitudes = () => {
|
||||||
|
const now = performance.now()
|
||||||
|
if (now - lastAltUpdate < 80) return // ~12Hz is plenty
|
||||||
|
lastAltUpdate = now
|
||||||
|
markersRef.current.forEach(marker => {
|
||||||
|
const ll = marker.getLngLat()
|
||||||
|
let alt = 0
|
||||||
|
try {
|
||||||
|
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
||||||
|
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
||||||
|
} catch { /* terrain not ready */ }
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const curAlt = (ll as any).alt ?? 0
|
||||||
|
if (Math.abs(curAlt - alt) > 0.25) {
|
||||||
|
marker.setLngLat([ll.lng, ll.lat, alt])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
map.on('render', syncMarkerAltitudes)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canvas.removeEventListener('mousedown', onAuxDown)
|
||||||
|
canvas.removeEventListener('auxclick', onAuxClick)
|
||||||
|
markersRef.current.forEach(m => m.remove())
|
||||||
|
markersRef.current.clear()
|
||||||
|
if (reservationOverlayRef.current) {
|
||||||
|
reservationOverlayRef.current.destroy()
|
||||||
|
reservationOverlayRef.current = null
|
||||||
|
}
|
||||||
|
if (locationMarkerRef.current) {
|
||||||
|
locationMarkerRef.current.destroy()
|
||||||
|
locationMarkerRef.current = null
|
||||||
|
}
|
||||||
|
try { map.remove() } catch { /* noop */ }
|
||||||
|
mapRef.current = null
|
||||||
|
setMapReady(false)
|
||||||
|
}
|
||||||
|
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
|
||||||
|
|
||||||
|
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
||||||
|
// simultaneous thumb arrivals into one re-render.
|
||||||
|
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||||
|
const thumbRafRef = useRef<number | null>(null)
|
||||||
|
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||||
|
const cleanups: (() => void)[] = []
|
||||||
|
|
||||||
|
const setThumb = (cacheKey: string, thumb: string) => {
|
||||||
|
pendingThumbsRef.current[cacheKey] = thumb
|
||||||
|
if (thumbRafRef.current !== null) return
|
||||||
|
thumbRafRef.current = requestAnimationFrame(() => {
|
||||||
|
thumbRafRef.current = null
|
||||||
|
const pending = pendingThumbsRef.current
|
||||||
|
pendingThumbsRef.current = {}
|
||||||
|
setPhotoUrls(prev => {
|
||||||
|
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||||
|
return hasChange ? { ...prev, ...pending } : prev
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const place of places) {
|
||||||
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
|
if (!cacheKey) continue
|
||||||
|
const cached = getCached(cacheKey)
|
||||||
|
if (cached?.thumbDataUrl) {
|
||||||
|
setThumb(cacheKey, cached.thumbDataUrl)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||||
|
if (!cached && !isLoading(cacheKey)) {
|
||||||
|
const photoId =
|
||||||
|
(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)) {
|
||||||
|
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanups.forEach(fn => fn())
|
||||||
|
if (thumbRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(thumbRafRef.current)
|
||||||
|
thumbRafRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Reconcile markers with places + photos. Rebuilds the DOM node when any
|
||||||
|
// visual input changes so photos, selection state and order badges stay
|
||||||
|
// in sync.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
const ids = new Set(places.map(p => p.id))
|
||||||
|
|
||||||
|
markersRef.current.forEach((marker, id) => {
|
||||||
|
if (!ids.has(id)) {
|
||||||
|
marker.remove()
|
||||||
|
markersRef.current.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
places.forEach(place => {
|
||||||
|
if (!place.lat || !place.lng) return
|
||||||
|
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||||
|
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
|
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||||
|
const selected = place.id === selectedPlaceId
|
||||||
|
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||||
|
el.addEventListener('click', (ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
onClickRefs.current.marker?.(place.id)
|
||||||
|
})
|
||||||
|
// Recreate marker each time rather than patching internal state —
|
||||||
|
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||||
|
const existing = markersRef.current.get(place.id)
|
||||||
|
if (existing) existing.remove()
|
||||||
|
// Default (viewport-aligned) anchors keep the marker parallel to the
|
||||||
|
// screen so its pixel centre lines up with the route line at any
|
||||||
|
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||||
|
// but it rotates the element by the pitch angle and visually offsets
|
||||||
|
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||||
|
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||||
|
.setLngLat([place.lng, place.lat])
|
||||||
|
.addTo(map)
|
||||||
|
markersRef.current.set(place.id, m)
|
||||||
|
})
|
||||||
|
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||||
|
|
||||||
|
// Update route geojson
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (!src) return
|
||||||
|
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {},
|
||||||
|
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||||
|
}))
|
||||||
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
|
}, [route])
|
||||||
|
|
||||||
|
// Update GPX geometries
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (!src) return
|
||||||
|
const features = places.flatMap(place => {
|
||||||
|
if (!place.route_geometry) return []
|
||||||
|
try {
|
||||||
|
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||||
|
if (!coords || coords.length < 2) return []
|
||||||
|
return [{
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
|
||||||
|
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||||
|
}]
|
||||||
|
} catch { return [] }
|
||||||
|
})
|
||||||
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
|
}, [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
|
||||||
|
const paddingOpts = useMemo(() => {
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
|
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
|
||||||
|
const top = 60
|
||||||
|
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||||
|
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||||
|
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||||
|
|
||||||
|
const prevFitKey = useRef(-1)
|
||||||
|
useEffect(() => {
|
||||||
|
if (fitKey === prevFitKey.current) return
|
||||||
|
prevFitKey.current = fitKey
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||||
|
const valid = target.filter(p => p.lat && p.lng)
|
||||||
|
if (valid.length === 0) return
|
||||||
|
const bounds = new mapboxgl.LngLatBounds()
|
||||||
|
valid.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||||
|
const run = () => {
|
||||||
|
try {
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: paddingOpts,
|
||||||
|
maxZoom: 15,
|
||||||
|
pitch: mapbox3d ? 45 : 0,
|
||||||
|
duration: 400,
|
||||||
|
})
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
if (map.loaded()) run()
|
||||||
|
else map.once('load', run)
|
||||||
|
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// flyTo selected place
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map || !selectedPlaceId) return
|
||||||
|
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
|
||||||
|
if (!target?.lat || !target?.lng) return
|
||||||
|
try {
|
||||||
|
map.flyTo({
|
||||||
|
center: [target.lng, target.lat],
|
||||||
|
zoom: Math.max(map.getZoom(), 14),
|
||||||
|
pitch: mapbox3d ? 45 : 0,
|
||||||
|
duration: 400,
|
||||||
|
})
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// External center/zoom prop changes — jump without animation
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
try { map.jumpTo({ center: [center[1], center[0]], zoom }) } catch { /* noop */ }
|
||||||
|
}, [center[0], center[1]]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Blue dot rendering + follow-mode camera. Attach the marker lazily the
|
||||||
|
// first time a fix arrives so the layers sit on top of everything else
|
||||||
|
// added so far, and destroy it when tracking is turned off.
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current
|
||||||
|
if (!map) return
|
||||||
|
if (trackingMode === 'off') {
|
||||||
|
if (locationMarkerRef.current) {
|
||||||
|
locationMarkerRef.current.update(null)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!userPosition) return
|
||||||
|
const apply = () => {
|
||||||
|
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
|
||||||
|
locationMarkerRef.current.update(userPosition)
|
||||||
|
if (trackingMode === 'follow') {
|
||||||
|
// easeTo is gentler than flyTo for continuous updates
|
||||||
|
try {
|
||||||
|
map.easeTo({
|
||||||
|
center: [userPosition.lng, userPosition.lat],
|
||||||
|
bearing: userPosition.heading ?? map.getBearing(),
|
||||||
|
zoom: Math.max(map.getZoom(), 16),
|
||||||
|
duration: 350,
|
||||||
|
})
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (map.loaded()) apply()
|
||||||
|
else map.once('load', apply)
|
||||||
|
}, [userPosition, trackingMode])
|
||||||
|
|
||||||
|
if (!mapboxToken) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
No Mapbox access token configured.<br />
|
||||||
|
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||||
|
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
|
const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full relative">
|
||||||
|
<div ref={containerRef} className="w-full h-full" />
|
||||||
|
{isMobile && (
|
||||||
|
<LocationButton
|
||||||
|
mode={trackingMode}
|
||||||
|
error={trackingError}
|
||||||
|
onClick={cycleTrackingMode}
|
||||||
|
bottomOffset={buttonBottom as unknown as number}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import mapboxgl from 'mapbox-gl'
|
||||||
|
import type { GeoPosition } from '../../hooks/useGeolocation'
|
||||||
|
|
||||||
|
// Build the DOM element that backs the mapbox Marker. We animate the
|
||||||
|
// heading cone via a CSS rotation so the DOM stays stable across updates
|
||||||
|
// and mapbox doesn't get confused about which element to position.
|
||||||
|
function buildLocationEl(): { root: HTMLDivElement; cone: HTMLDivElement } {
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.style.cssText = 'width:28px;height:28px;position:relative;pointer-events:none;'
|
||||||
|
// Accuracy pulse behind the dot
|
||||||
|
const pulse = document.createElement('div')
|
||||||
|
pulse.style.cssText = `
|
||||||
|
position:absolute;inset:-14px;border-radius:50%;
|
||||||
|
background:#3b82f6;opacity:0.25;
|
||||||
|
animation:trek-location-pulse 2s ease-out infinite;
|
||||||
|
`
|
||||||
|
// Heading cone (conic gradient fan)
|
||||||
|
const cone = document.createElement('div')
|
||||||
|
cone.style.cssText = `
|
||||||
|
position:absolute;left:50%;top:50%;width:60px;height:60px;
|
||||||
|
transform:translate(-50%,-50%) rotate(0deg);
|
||||||
|
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
|
||||||
|
border-radius:50%;
|
||||||
|
mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||||
|
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||||
|
transition:transform 0.12s ease-out;
|
||||||
|
display:none;
|
||||||
|
`
|
||||||
|
// Blue dot
|
||||||
|
const dot = document.createElement('div')
|
||||||
|
dot.style.cssText = `
|
||||||
|
position:absolute;left:50%;top:50%;
|
||||||
|
transform:translate(-50%,-50%);
|
||||||
|
width:18px;height:18px;border-radius:50%;
|
||||||
|
background:#3b82f6;border:3px solid white;
|
||||||
|
box-shadow:0 0 0 1px rgba(0,0,0,0.15), 0 2px 6px rgba(0,0,0,0.3);
|
||||||
|
`
|
||||||
|
root.appendChild(pulse)
|
||||||
|
root.appendChild(cone)
|
||||||
|
root.appendChild(dot)
|
||||||
|
return { root, cone }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the pulse keyframes once per document so the animation is
|
||||||
|
// available for every map instance.
|
||||||
|
function ensurePulseStyle() {
|
||||||
|
if (document.getElementById('trek-location-style')) return
|
||||||
|
const s = document.createElement('style')
|
||||||
|
s.id = 'trek-location-style'
|
||||||
|
s.textContent = `
|
||||||
|
@keyframes trek-location-pulse {
|
||||||
|
0% { transform: scale(0.6); opacity: 0.35; }
|
||||||
|
70% { transform: scale(1.6); opacity: 0; }
|
||||||
|
100% { transform: scale(1.6); opacity: 0; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationMarkerHandle {
|
||||||
|
update: (p: GeoPosition | null) => void
|
||||||
|
destroy: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates (or reuses) a location marker + accuracy circle on the given
|
||||||
|
// mapbox map. Returns a handle the caller uses to push position updates
|
||||||
|
// and clean up. Keeps its own DOM element and GeoJSON source so it can
|
||||||
|
// coexist with the regular trip markers.
|
||||||
|
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
|
||||||
|
ensurePulseStyle()
|
||||||
|
const { root, cone } = buildLocationEl()
|
||||||
|
const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
|
||||||
|
|
||||||
|
const ensureAccuracyLayer = () => {
|
||||||
|
if (map.getSource('trek-location-accuracy')) return
|
||||||
|
try {
|
||||||
|
map.addSource('trek-location-accuracy', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: { type: 'FeatureCollection', features: [] },
|
||||||
|
})
|
||||||
|
// Draw the accuracy ring as a geographic polygon: it's a real geodesic
|
||||||
|
// circle defined in meters, so mapbox automatically scales it with
|
||||||
|
// zoom the way Apple/Google Maps does — always the same real-world
|
||||||
|
// size regardless of viewport.
|
||||||
|
map.addLayer({
|
||||||
|
id: 'trek-location-accuracy',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'trek-location-accuracy',
|
||||||
|
paint: {
|
||||||
|
'fill-color': '#3b82f6',
|
||||||
|
'fill-opacity': 0.14,
|
||||||
|
'fill-outline-color': '#3b82f6',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a polygon approximating a geodesic circle around (lng, lat)
|
||||||
|
// with the given radius in meters. 48 segments is plenty for a smooth
|
||||||
|
// edge without paying much CPU per fix.
|
||||||
|
const geodesicCircle = (lng: number, lat: number, radiusMeters: number): number[][] => {
|
||||||
|
const earth = 6378137
|
||||||
|
const d = radiusMeters / earth
|
||||||
|
const lat1 = lat * Math.PI / 180
|
||||||
|
const lng1 = lng * Math.PI / 180
|
||||||
|
const coords: number[][] = []
|
||||||
|
const segments = 48
|
||||||
|
for (let i = 0; i <= segments; i++) {
|
||||||
|
const bearing = (i / segments) * 2 * Math.PI
|
||||||
|
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(bearing))
|
||||||
|
const lng2 = lng1 + Math.atan2(
|
||||||
|
Math.sin(bearing) * Math.sin(d) * Math.cos(lat1),
|
||||||
|
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2),
|
||||||
|
)
|
||||||
|
coords.push([lng2 * 180 / Math.PI, lat2 * 180 / Math.PI])
|
||||||
|
}
|
||||||
|
return coords
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAccuracy = (p: GeoPosition) => {
|
||||||
|
const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
if (!src) return
|
||||||
|
if (!p.accuracy || p.accuracy < 1) {
|
||||||
|
src.setData({ type: 'FeatureCollection', features: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ring = geodesicCircle(p.lng, p.lat, p.accuracy)
|
||||||
|
src.setData({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {},
|
||||||
|
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastPosRef: GeoPosition | null = null
|
||||||
|
|
||||||
|
if (map.loaded()) ensureAccuracyLayer()
|
||||||
|
else map.once('load', ensureAccuracyLayer)
|
||||||
|
|
||||||
|
const handle: LocationMarkerHandle = {
|
||||||
|
update: (p) => {
|
||||||
|
lastPosRef = p
|
||||||
|
if (!p) {
|
||||||
|
marker.remove()
|
||||||
|
const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined
|
||||||
|
src?.setData({ type: 'FeatureCollection', features: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
marker.setLngLat([p.lng, p.lat])
|
||||||
|
if (!marker.getElement().parentElement) marker.addTo(map)
|
||||||
|
if (p.heading !== null && !Number.isNaN(p.heading)) {
|
||||||
|
cone.style.display = 'block'
|
||||||
|
cone.style.transform = `translate(-50%,-50%) rotate(${p.heading}deg)`
|
||||||
|
} else {
|
||||||
|
cone.style.display = 'none'
|
||||||
|
}
|
||||||
|
setAccuracy(p)
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
try { marker.remove() } catch { /* noop */ }
|
||||||
|
try {
|
||||||
|
if (map.getLayer('trek-location-accuracy')) map.removeLayer('trek-location-accuracy')
|
||||||
|
if (map.getSource('trek-location-accuracy')) map.removeSource('trek-location-accuracy')
|
||||||
|
} catch { /* noop */ }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type mapboxgl from 'mapbox-gl'
|
||||||
|
|
||||||
|
// "mapbox/standard" and "mapbox/standard-satellite" ship their own 3D
|
||||||
|
// buildings and terrain. For every other style we inject a fill-extrusion
|
||||||
|
// layer against the classic `composite` vector source so the user still
|
||||||
|
// gets real 3D buildings (not just a tilted 2D view) when they toggle 3D.
|
||||||
|
export function isStandardFamily(style: string): boolean {
|
||||||
|
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terrain is only genuinely useful for the satellite imagery styles — on
|
||||||
|
// clean flat styles like streets/light/dark it nudges route lines onto
|
||||||
|
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
||||||
|
// offset when the map is pitched. Restrict terrain to satellite.
|
||||||
|
export function wantsTerrain(style: string): boolean {
|
||||||
|
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||||
|
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D can be added to every style now — the standard family has it built-in
|
||||||
|
// and for everything else we either reuse the style's own `composite`
|
||||||
|
// building layer or attach the public `mapbox-streets-v8` tileset as an
|
||||||
|
// extra source (needed for pure satellite, which has no vector data).
|
||||||
|
export function supportsCustom3d(style: string): boolean {
|
||||||
|
return !isStandardFamily(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a 3D buildings extrusion layer to a non-Standard Mapbox style. For
|
||||||
|
// the pure satellite style we lazily attach `mapbox-streets-v8` as a
|
||||||
|
// fallback source so real building volumes sit on top of the imagery —
|
||||||
|
// the Apple Maps-style "3D satellite" look the user asked for.
|
||||||
|
export function addCustom3dBuildings(map: mapboxgl.Map, dark: boolean) {
|
||||||
|
if (map.getLayer('trek-3d-buildings')) return
|
||||||
|
const baseColor = dark ? '#3b3b3f' : '#cfd2d6'
|
||||||
|
|
||||||
|
// Styles without a `composite` source (pure satellite) need a fallback
|
||||||
|
// vector tileset for building geometry.
|
||||||
|
let sourceId = 'composite'
|
||||||
|
if (!map.getSource('composite')) {
|
||||||
|
sourceId = 'mapbox-streets-v8'
|
||||||
|
if (!map.getSource(sourceId)) {
|
||||||
|
try {
|
||||||
|
map.addSource(sourceId, { type: 'vector', url: 'mapbox://mapbox.mapbox-streets-v8' })
|
||||||
|
} catch { return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Place extrusions below the first label layer so text stays readable.
|
||||||
|
const layers = map.getStyle()?.layers || []
|
||||||
|
const firstSymbolId = layers.find(l => l.type === 'symbol')?.id
|
||||||
|
map.addLayer({
|
||||||
|
id: 'trek-3d-buildings',
|
||||||
|
source: sourceId,
|
||||||
|
'source-layer': 'building',
|
||||||
|
filter: ['==', 'extrude', 'true'],
|
||||||
|
type: 'fill-extrusion',
|
||||||
|
minzoom: 14,
|
||||||
|
paint: {
|
||||||
|
'fill-extrusion-color': baseColor,
|
||||||
|
'fill-extrusion-height': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
14, 0,
|
||||||
|
15.5, ['coalesce', ['get', 'height'], 0],
|
||||||
|
],
|
||||||
|
'fill-extrusion-base': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
14, 0,
|
||||||
|
15.5, ['coalesce', ['get', 'min_height'], 0],
|
||||||
|
],
|
||||||
|
'fill-extrusion-opacity': 0.85,
|
||||||
|
},
|
||||||
|
}, firstSymbolId)
|
||||||
|
} catch { /* building source-layer unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terrain + sky that works against any style that has the DEM source.
|
||||||
|
// The Standard family already handles terrain internally, skip there.
|
||||||
|
export function addTerrainAndSky(map: mapboxgl.Map) {
|
||||||
|
try {
|
||||||
|
if (!map.getSource('mapbox-dem')) {
|
||||||
|
map.addSource('mapbox-dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||||
|
tileSize: 512,
|
||||||
|
maxzoom: 14,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
map.setTerrain({ source: 'mapbox-dem', exaggeration: 1.2 })
|
||||||
|
if (!map.getLayer('sky')) {
|
||||||
|
map.addLayer({
|
||||||
|
id: 'sky',
|
||||||
|
type: 'sky',
|
||||||
|
paint: {
|
||||||
|
'sky-type': 'atmosphere',
|
||||||
|
'sky-atmosphere-sun-intensity': 15,
|
||||||
|
} as unknown as mapboxgl.SkyLayerSpecification['paint'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch { /* style doesn't support terrain */ }
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
// 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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -582,7 +582,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
}}>
|
}}>
|
||||||
{t('memories.allPhotos')}
|
<span className="hidden sm:inline">{t('memories.allPhotos')}</span>
|
||||||
|
<span className="sm:hidden">{t('common.all')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ 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' }),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 ''
|
||||||
@@ -96,12 +97,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)
|
const toFetch = unique.filter(p => !p.image_url && (p.google_place_id || p.osm_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)
|
const data = await mapsApi.placePhoto(place.google_place_id || place.osm_id, place.lat, place.lng, place.name)
|
||||||
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
if (data.photoUrl) photoMap[place.id] = data.photoUrl
|
||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
@@ -140,23 +141,58 @@ 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)
|
// Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
|
||||||
const dayReservations = (reservations || []).filter(r => {
|
const dayReservations = pdfGetTransportForDay(day.id)
|
||||||
if (!r.reservation_time || r.type === 'hotel') return false
|
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
|
||||||
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_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
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)
|
||||||
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)
|
||||||
@@ -177,13 +213,17 @@ 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 time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
|
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;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${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>` : ''}
|
||||||
@@ -246,8 +286,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
}).join('')
|
}).join('')
|
||||||
|
|
||||||
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
).sort((a, b) => a.start_day_id - b.start_day_id)
|
).sort((a, b) => {
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Package } from 'lucide-react'
|
||||||
|
import { adminApi, packingApi } from '../../api/client'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
item_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplyTemplateButtonProps {
|
||||||
|
tripId: number
|
||||||
|
style: React.CSSProperties
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropdown-Button um ein Packing-Template auf den aktuellen Trip anzuwenden.
|
||||||
|
// Rendert nichts wenn keine Templates existieren.
|
||||||
|
export default function ApplyTemplateButton({ tripId, style, className }: ApplyTemplateButtonProps): React.ReactElement | null {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (dropRef.current && !dropRef.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handleApply = async (templateId: number) => {
|
||||||
|
setApplying(true)
|
||||||
|
try {
|
||||||
|
const data = await packingApi.applyTemplate(tripId, templateId)
|
||||||
|
useTripStore.setState(s => ({ packingItems: [...s.packingItems, ...(data.items || [])] }))
|
||||||
|
toast.success(t('packing.templateApplied', { count: data.count }))
|
||||||
|
setOpen(false)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('packing.templateError'))
|
||||||
|
} finally {
|
||||||
|
setApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templates.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropRef} style={{ position: 'relative' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
disabled={applying}
|
||||||
|
className={className ?? 'hover:opacity-[0.88]'}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<Package size={14} strokeWidth={2.5} />
|
||||||
|
<span className="hidden sm:inline">{t('packing.applyTemplate')}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
className="trek-menu-enter"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', right: 0, top: '100%', marginTop: 6, 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,
|
||||||
|
transformOrigin: 'top right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{templates.map(tmpl => (
|
||||||
|
<button key={tmpl.id} onClick={() => handleApply(tmpl.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '8px 12px', borderRadius: 8, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'transparent', fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<Package size={13} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<div style={{ flex: 1, textAlign: 'left' }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{tmpl.name}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text-faint)' }}>
|
||||||
|
{tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -208,9 +208,14 @@ 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(item.name)
|
const [editName, setEditName] = useState(isPlaceholder ? '' : 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)
|
||||||
@@ -223,7 +228,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(item.name); return }
|
if (!editName.trim()) { setEditing(false); setEditName(isPlaceholder ? '' : 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')) }
|
||||||
}
|
}
|
||||||
@@ -253,18 +258,32 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button onClick={handleToggle} style={{
|
<button onClick={handleToggle} style={{
|
||||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
|
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, position: 'relative',
|
||||||
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
|
width: 18, height: 18,
|
||||||
|
color: item.checked ? '#10b981' : 'var(--text-faint)',
|
||||||
|
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
}}>
|
}}>
|
||||||
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
<Square size={18} style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
opacity: item.checked ? 0 : 1,
|
||||||
|
transform: item.checked ? 'scale(0.7)' : 'scale(1)',
|
||||||
|
transition: 'opacity 180ms cubic-bezier(0.23,1,0.32,1), transform 180ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
|
}} />
|
||||||
|
<CheckSquare size={18} style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
opacity: item.checked ? 1 : 0,
|
||||||
|
transform: item.checked ? 'scale(1)' : 'scale(0.5)',
|
||||||
|
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 220ms cubic-bezier(0.34,1.56,0.64,1)',
|
||||||
|
}} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{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(item.name) } }}
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(isPlaceholder ? '' : 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' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -273,7 +292,8 @@ 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: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
color: isPlaceholder ? 'var(--text-faint)' : (item.checked ? 'var(--text-faint)' : 'var(--text-primary)'),
|
||||||
|
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
textDecoration: item.checked ? 'line-through' : 'none',
|
textDecoration: item.checked ? 'line-through' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -730,10 +750,12 @@ interface PackingListPanelProps {
|
|||||||
tripId: number
|
tripId: number
|
||||||
items: PackingItem[]
|
items: PackingItem[]
|
||||||
openImportSignal?: number
|
openImportSignal?: number
|
||||||
|
clearCheckedSignal?: number
|
||||||
|
saveTemplateSignal?: number
|
||||||
inlineHeader?: boolean
|
inlineHeader?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PackingListPanel({ tripId, items, openImportSignal = 0, inlineHeader = true }: PackingListPanelProps) {
|
export default function PackingListPanel({ tripId, items, openImportSignal = 0, clearCheckedSignal = 0, saveTemplateSignal = 0, inlineHeader = true }: PackingListPanelProps) {
|
||||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
||||||
const [addingCategory, setAddingCategory] = useState(false)
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
@@ -899,6 +921,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
const [showImportModal, setShowImportModal] = useState(false)
|
const [showImportModal, setShowImportModal] = useState(false)
|
||||||
const [importText, setImportText] = useState('')
|
const [importText, setImportText] = useState('')
|
||||||
const lastHandledImportSignal = useRef(openImportSignal)
|
const lastHandledImportSignal = useRef(openImportSignal)
|
||||||
|
const lastHandledClearSignal = useRef(clearCheckedSignal)
|
||||||
|
const lastHandledSaveSignal = useRef(saveTemplateSignal)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
|
if (openImportSignal !== lastHandledImportSignal.current && openImportSignal > 0) {
|
||||||
@@ -906,6 +930,21 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
}
|
}
|
||||||
lastHandledImportSignal.current = openImportSignal
|
lastHandledImportSignal.current = openImportSignal
|
||||||
}, [openImportSignal])
|
}, [openImportSignal])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (clearCheckedSignal !== lastHandledClearSignal.current && clearCheckedSignal > 0) {
|
||||||
|
handleClearChecked()
|
||||||
|
}
|
||||||
|
lastHandledClearSignal.current = clearCheckedSignal
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [clearCheckedSignal])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveTemplateSignal !== lastHandledSaveSignal.current && saveTemplateSignal > 0) {
|
||||||
|
setShowSaveTemplate(true)
|
||||||
|
}
|
||||||
|
lastHandledSaveSignal.current = saveTemplateSignal
|
||||||
|
}, [saveTemplateSignal])
|
||||||
const csvInputRef = useRef<HTMLInputElement>(null)
|
const csvInputRef = useRef<HTMLInputElement>(null)
|
||||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -926,10 +965,9 @@ 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 {
|
||||||
@@ -987,10 +1025,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')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1020,14 +1058,22 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : <span />}
|
||||||
items.length > 0 ? (
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-faint)' }}>
|
{canEdit && items.length > 0 && showSaveTemplate && (
|
||||||
{t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
</p>
|
<input
|
||||||
) : <span />
|
type="text" autoFocus
|
||||||
)}
|
value={saveTemplateName}
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
onChange={e => setSaveTemplateName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
|
||||||
|
placeholder={t('packing.templateName')}
|
||||||
|
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
||||||
|
/>
|
||||||
|
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
|
||||||
|
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{inlineHeader && canEdit && (
|
{inlineHeader && canEdit && (
|
||||||
<button onClick={() => setShowImportModal(true)} style={{
|
<button onClick={() => setShowImportModal(true)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
@@ -1037,7 +1083,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
<Upload size={12} /> <span className="hidden sm:inline">{t('packing.import')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEdit && abgehakt > 0 && (
|
{inlineHeader && canEdit && abgehakt > 0 && (
|
||||||
<button onClick={handleClearChecked} style={{
|
<button onClick={handleClearChecked} style={{
|
||||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
@@ -1046,7 +1092,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEdit && availableTemplates.length > 0 && (
|
{inlineHeader && canEdit && availableTemplates.length > 0 && (
|
||||||
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
<div ref={templateDropdownRef} style={{ position: 'relative' }}>
|
||||||
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
<button onClick={() => setShowTemplateDropdown(v => !v)} disabled={applyingTemplate} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
@@ -1085,31 +1131,14 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && items.length > 0 && (
|
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
|
||||||
<div style={{ position: 'relative' }}>
|
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||||
{showSaveTemplate ? (
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
<input
|
background: 'var(--bg-card)', color: 'var(--text-muted)',
|
||||||
type="text" autoFocus
|
}}>
|
||||||
value={saveTemplateName}
|
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
||||||
onChange={e => setSaveTemplateName(e.target.value)}
|
</button>
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveAsTemplate(); if (e.key === 'Escape') { setShowSaveTemplate(false); setSaveTemplateName('') } }}
|
|
||||||
placeholder={t('packing.templateName')}
|
|
||||||
style={{ fontSize: 12, padding: '5px 10px', borderRadius: 99, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit', width: 140, background: 'var(--bg-card)', color: 'var(--text-primary)' }}
|
|
||||||
/>
|
|
||||||
<button onClick={handleSaveAsTemplate} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: '#10b981' }}><Check size={14} /></button>
|
|
||||||
<button onClick={() => { setShowSaveTemplate(false); setSaveTemplateName('') }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)' }}><X size={14} /></button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => setShowSaveTemplate(true)} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
|
||||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
background: 'var(--bg-card)', color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
<FolderPlus size={12} /> <span className="hidden sm:inline">{t('packing.saveAsTemplate')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{bagTrackingEnabled && (
|
{bagTrackingEnabled && (
|
||||||
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
|
<button onClick={() => setShowBagModal(true)} className="xl:!hidden"
|
||||||
@@ -1127,17 +1156,69 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div style={{ marginBottom: 14 }}>
|
<div className="hidden sm:block" style={{ marginTop: 14, marginBottom: 14 }}>
|
||||||
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
<div className="flex items-center" style={{ gap: 14 }}>
|
||||||
|
{fortschritt === 100 ? (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
fontSize: 16, fontWeight: 700, color: '#10b981',
|
||||||
|
letterSpacing: '-0.01em', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<CheckCheck size={18} strokeWidth={2.5} />
|
||||||
|
<span>{t('packing.allPacked')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
|
||||||
|
fontVariantNumeric: 'tabular-nums', letterSpacing: '-0.02em',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}>{abgehakt}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 14, fontWeight: 500, color: 'var(--text-faint)',
|
||||||
|
fontVariantNumeric: 'tabular-nums', lineHeight: 1, marginLeft: 1,
|
||||||
|
}}>/{items.length}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 600, padding: '2px 7px',
|
||||||
|
borderRadius: 99, background: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
}}>{fortschritt}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
|
flex: 1,
|
||||||
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
|
height: 8,
|
||||||
width: `${fortschritt}%`,
|
background: 'var(--bg-tertiary)',
|
||||||
}} />
|
borderRadius: 99,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 99,
|
||||||
|
transition: 'width 600ms cubic-bezier(0.23, 1, 0.32, 1), background 400ms ease, box-shadow 400ms ease',
|
||||||
|
background: fortschritt === 100
|
||||||
|
? 'linear-gradient(90deg, #10b981 0%, #34d399 100%)'
|
||||||
|
: 'var(--accent)',
|
||||||
|
width: `${fortschritt}%`,
|
||||||
|
boxShadow: fortschritt === 100 ? '0 0 14px rgba(16,185,129,0.45)' : 'none',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 55%)',
|
||||||
|
borderRadius: 99,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{fortschritt === 100 && (
|
|
||||||
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -892,6 +892,277 @@ 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,6 +12,7 @@ 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,
|
||||||
@@ -66,7 +67,11 @@ 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) => formatTime12(v, is12h)
|
const fmtTime = (v) => {
|
||||||
|
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?.()
|
||||||
@@ -95,7 +100,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 =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
)
|
)
|
||||||
setDayAccommodations(allForDay)
|
setDayAccommodations(allForDay)
|
||||||
setAccommodation(allForDay[0] || null)
|
setAccommodation(allForDay[0] || null)
|
||||||
@@ -126,7 +131,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 =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
))
|
))
|
||||||
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 })
|
||||||
@@ -150,7 +155,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 =>
|
||||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
|
||||||
))
|
))
|
||||||
setAccommodation(null)
|
setAccommodation(null)
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
@@ -168,7 +173,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 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 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 style={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
@@ -459,10 +464,13 @@ 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: Math.max(v, prev.end) }))}
|
onChange={v => setHotelDayRange(prev => ({ start: v, end: days.findIndex(d => d.id === v) > days.findIndex(d => d.id === prev.end) ? 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 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -471,10 +479,13 @@ 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: Math.min(prev.start, v), end: v }))}
|
onChange={v => setHotelDayRange(prev => ({ start: days.findIndex(d => d.id === v) < days.findIndex(d => d.id === prev.start) ? v : prev.start, 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 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
label: d.title || t('planner.dayN', { n: i + 1 }),
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -588,9 +599,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 =>
|
||||||
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
|
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))
|
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)
|
||||||
setAccommodation(acc || null)
|
setAccommodation(acc || null)
|
||||||
})
|
})
|
||||||
onAccommodationChange?.()
|
onAccommodationChange?.()
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const assignments = { '10': [assignment] }
|
const assignments = { '10': [assignment] }
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||||
// The chevron button immediately follows the "Add Note" button (which has a title attribute)
|
// The chevron button immediately follows the "Add Note" button (which has a title attribute)
|
||||||
const addNoteBtn = screen.getByTitle('Add Note')
|
const addNoteBtn = screen.getByLabelText('Add Note')
|
||||||
const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement
|
const chevron = addNoteBtn.nextElementSibling as HTMLButtonElement
|
||||||
expect(chevron).toBeTruthy()
|
expect(chevron).toBeTruthy()
|
||||||
await user.click(chevron)
|
await user.click(chevron)
|
||||||
@@ -201,7 +201,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||||
const assignments = { '10': [assignment] }
|
const assignments = { '10': [assignment] }
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments })} />)
|
||||||
const getChevron = () => screen.getByTitle('Add Note').nextElementSibling as HTMLButtonElement
|
const getChevron = () => screen.getByLabelText('Add Note').nextElementSibling as HTMLButtonElement
|
||||||
await user.click(getChevron()) // collapse
|
await user.click(getChevron()) // collapse
|
||||||
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument()
|
||||||
await user.click(getChevron()) // re-expand
|
await user.click(getChevron()) // re-expand
|
||||||
@@ -362,28 +362,14 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const onUndo = vi.fn()
|
const onUndo = vi.fn()
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: true, lastActionLabel: 'Removed place', onUndo })} />)
|
||||||
// Find the undo button — it has width 30, height 30 and is not disabled
|
const undoBtn = screen.getByLabelText('Undo')
|
||||||
const buttons = screen.getAllByRole('button')
|
await user.click(undoBtn)
|
||||||
// The undo button is the one with the Undo2 icon and is not disabled
|
expect(onUndo).toHaveBeenCalled()
|
||||||
const undoBtn = buttons.find(btn => {
|
|
||||||
const style = btn.getAttribute('style') || ''
|
|
||||||
return style.includes('width: 30px') || style.includes('width:30px') || (style.includes('30') && !btn.disabled)
|
|
||||||
})
|
|
||||||
if (undoBtn) {
|
|
||||||
await user.click(undoBtn)
|
|
||||||
expect(onUndo).toHaveBeenCalled()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-024: undo button not present when onUndo not provided', () => {
|
it('FE-PLANNER-DAYPLAN-024: undo button not present when onUndo not provided', () => {
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: false })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ canUndo: false })} />)
|
||||||
// When onUndo is not provided, the undo section is not rendered at all
|
expect(screen.queryByLabelText('Undo')).toBeNull()
|
||||||
const buttons = screen.getAllByRole('button')
|
|
||||||
const undoBtn = buttons.find(btn => {
|
|
||||||
const style = btn.getAttribute('style') || ''
|
|
||||||
return style.includes('width: 30px')
|
|
||||||
})
|
|
||||||
expect(undoBtn).toBeUndefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PDF export ──────────────────────────────────────────────────────────
|
// ── PDF export ──────────────────────────────────────────────────────────
|
||||||
@@ -931,7 +917,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
const addNoteBtn = screen.getByTitle('Add Note')
|
const addNoteBtn = screen.getByLabelText('Add Note')
|
||||||
await user.click(addNoteBtn)
|
await user.click(addNoteBtn)
|
||||||
expect(mockDayNotesState.openAddNote).toHaveBeenCalled()
|
expect(mockDayNotesState.openAddNote).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
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, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, 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'
|
||||||
|
|
||||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
@@ -14,6 +14,7 @@ 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'
|
||||||
@@ -21,8 +22,15 @@ 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 type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||||
|
|
||||||
const NOTE_ICONS = [
|
const NOTE_ICONS = [
|
||||||
@@ -188,6 +196,8 @@ 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({
|
||||||
@@ -216,6 +226,8 @@ 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()
|
||||||
@@ -268,7 +280,24 @@ 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
|
||||||
|
// keep yanking the user back whenever they scroll away while the same
|
||||||
|
// place stays selected.
|
||||||
|
const lastAutoScrolledIdRef = useRef<number | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset the scroll-lock whenever selection moves, so the next selected
|
||||||
|
// row triggers a fresh scroll-into-view on its ref.
|
||||||
|
if (!selectedAssignmentId && !selectedPlaceId) {
|
||||||
|
lastAutoScrolledIdRef.current = null
|
||||||
|
}
|
||||||
|
}, [selectedAssignmentId, selectedPlaceId])
|
||||||
|
|
||||||
const currency = trip?.currency || 'EUR'
|
const currency = trip?.currency || 'EUR'
|
||||||
|
|
||||||
@@ -324,6 +353,10 @@ 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 => {
|
||||||
@@ -334,26 +367,6 @@ 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
|
||||||
@@ -378,27 +391,8 @@ 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) =>
|
||||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
_getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days })
|
||||||
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) => {
|
||||||
@@ -418,20 +412,6 @@ 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
|
||||||
@@ -473,69 +453,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMergedItems = (dayId) => {
|
const getMergedItems = (dayId: number): MergedItem[] =>
|
||||||
const da = getDayAssignments(dayId)
|
_getMergedItems({
|
||||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
dayAssignments: getDayAssignments(dayId),
|
||||||
const transport = getTransportForDay(dayId)
|
dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
dayTransports: getTransportForDay(dayId),
|
||||||
// Initialize positions for transports that don't have one yet
|
dayId,
|
||||||
if (transport.some(r => r.day_plan_position == null)) {
|
getDisplayTime: getDisplayTimeForDay,
|
||||||
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
|
||||||
@@ -939,18 +864,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
{/* Reise-Titel */}
|
{/* Toolbar */}
|
||||||
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
|
|
||||||
{(trip?.start_date || trip?.end_date) && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
|
|
||||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' – ')}
|
|
||||||
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -1032,11 +948,57 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||||
|
const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||||
|
return (
|
||||||
|
<Tooltip label={label} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = allExpanded ? new Set() : new Set(days.map(d => d.id))
|
||||||
|
setExpandedDays(next)
|
||||||
|
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...next])) } catch {}
|
||||||
|
}}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={allExpanded}
|
||||||
|
style={{
|
||||||
|
position: 'relative', flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 30, height: 30, borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'none',
|
||||||
|
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||||
|
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
opacity: allExpanded ? 0 : 1,
|
||||||
|
transform: allExpanded ? 'translateY(-8px) scale(0.6)' : 'translateY(0) scale(1)',
|
||||||
|
}}>
|
||||||
|
<ChevronsUpDown size={14} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'opacity 0.2s ease, transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
opacity: allExpanded ? 1 : 0,
|
||||||
|
transform: allExpanded ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.6)',
|
||||||
|
}}>
|
||||||
|
<ChevronsDownUp size={14} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{onUndo && (
|
{onUndo && (
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
<button
|
<button
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
|
aria-label={t('undo.button')}
|
||||||
onMouseEnter={() => setUndoHover(true)}
|
onMouseEnter={() => setUndoHover(true)}
|
||||||
onMouseLeave={() => setUndoHover(false)}
|
onMouseLeave={() => setUndoHover(false)}
|
||||||
style={{
|
style={{
|
||||||
@@ -1068,7 +1030,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tagesliste */}
|
{/* Tagesliste */}
|
||||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||||
{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)
|
||||||
@@ -1086,14 +1048,14 @@ 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(); setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) 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={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
padding: '11px 14px 11px 16px',
|
padding: '11px 14px 11px 16px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-tertiary)' : 'transparent'),
|
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : 'transparent'),
|
||||||
transition: 'background 0.12s',
|
transition: 'background 0.12s',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||||
@@ -1143,9 +1105,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||||
</button>}
|
</button>}
|
||||||
{canEditDays && onAddTransport && (
|
{canEditDays && onAddTransport && (
|
||||||
|
<Tooltip label={t('transport.addTransport')} placement="top">
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
||||||
title={t('transport.addTransport')}
|
aria-label={t('transport.addTransport')}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background: 'none',
|
background: 'none',
|
||||||
@@ -1162,9 +1125,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
>
|
>
|
||||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||||
// 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
|
||||||
@@ -1185,9 +1149,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(); 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' }}>
|
<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' }}>
|
||||||
<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.place_name}</span>
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1217,15 +1181,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditDays && <button
|
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
||||||
onClick={e => openAddNote(day.id, e)}
|
onClick={e => openAddNote(day.id, e)}
|
||||||
title={t('dayplan.addNote')}
|
aria-label={t('dayplan.addNote')}
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||||
>
|
>
|
||||||
<FileText size={16} strokeWidth={2} />
|
<FileText size={16} strokeWidth={2} />
|
||||||
</button>}
|
</button></Tooltip>}
|
||||||
<button
|
<button
|
||||||
onClick={e => toggleDay(day.id, e)}
|
onClick={e => toggleDay(day.id, e)}
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||||
@@ -1298,7 +1262,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
>
|
>
|
||||||
{merged.length === 0 && !dayNoteUi ? (
|
{merged.length === 0 && !dayNoteUi ? (
|
||||||
<div
|
<div
|
||||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) 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',
|
||||||
@@ -1358,7 +1322,6 @@ 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 => {
|
||||||
@@ -1399,6 +1362,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
ref={el => {
|
||||||
|
// Auto-scroll the selected row into view — but only on
|
||||||
|
// the transition "just became selected". Once we've
|
||||||
|
// scrolled for this assignment id, we won't scroll
|
||||||
|
// again until selection actually moves somewhere else.
|
||||||
|
if (el && isPlaceSelected && lastAutoScrolledIdRef.current !== assignment.id) {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const nearTop = rect.top < 80
|
||||||
|
const nearBottom = rect.bottom > window.innerHeight - 80
|
||||||
|
if (nearTop || nearBottom) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
lastAutoScrolledIdRef.current = assignment.id
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||||
onContextMenu={e => ctxMenu.open(e, [
|
onContextMenu={e => ctxMenu.open(e, [
|
||||||
@@ -1429,10 +1407,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: lockedIds.has(assignment.id)
|
background: lockedIds.has(assignment.id)
|
||||||
? 'rgba(220,38,38,0.08)'
|
? 'rgba(220,38,38,0.08)'
|
||||||
: isPlaceSelected ? 'var(--bg-hover)' : 'transparent',
|
: isPlaceSelected ? 'var(--bg-selected)' : 'transparent',
|
||||||
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,
|
||||||
}}
|
}}
|
||||||
@@ -1511,7 +1490,10 @@ 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>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1535,7 +1517,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
background: active ? '#3b82f6' : 'transparent',
|
background: active ? '#3b82f6' : 'transparent',
|
||||||
color: active ? '#fff' : 'var(--text-faint)',
|
color: active ? '#fff' : 'var(--text-faint)',
|
||||||
transition: 'all 0.12s',
|
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
@@ -1558,7 +1540,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||||
border: 'none', background: 'transparent',
|
border: 'none', background: 'transparent',
|
||||||
color: 'var(--text-faint)',
|
color: 'var(--text-faint)',
|
||||||
transition: 'all 0.12s',
|
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
@@ -1656,9 +1638,12 @@ 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={() => canEditDays && onEditTransport?.(res)}
|
onClick={() => {
|
||||||
|
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()
|
||||||
@@ -1705,6 +1690,8 @@ 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',
|
||||||
@@ -1768,7 +1755,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
border: 'none',
|
border: 'none',
|
||||||
background: active ? color : 'transparent',
|
background: active ? color : 'transparent',
|
||||||
color: active ? '#fff' : 'var(--text-faint)',
|
color: active ? '#fff' : 'var(--text-faint)',
|
||||||
transition: 'all 0.12s',
|
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||||
@@ -1778,7 +1765,6 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1789,7 +1775,6 @@ 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}`) }}
|
||||||
@@ -1845,6 +1830,7 @@ 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',
|
||||||
@@ -2155,7 +2141,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' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -360,6 +360,25 @@ 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 */}
|
||||||
@@ -613,23 +632,6 @@ 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,6 +2,7 @@ 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'
|
||||||
@@ -349,8 +350,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' }}>
|
<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' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -399,7 +400,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 }}><Markdown remarkPlugins={[remarkGfm]}>{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, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{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
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ describe('Filter tabs', () => {
|
|||||||
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
|
||||||
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
render(<PlacesSidebar {...defaultProps} places={[planned, unplanned]} assignments={assignments} />);
|
||||||
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
|
||||||
await user.click(screen.getByRole('button', { name: /^All$/i }));
|
await user.click(screen.getByRole('button', { name: /^All/i }));
|
||||||
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
expect(screen.getByText('Planned Place')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, useRef, useCallback } from 'react'
|
import { useState, useMemo, useEffect, useLayoutEffect, 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'
|
||||||
@@ -13,6 +13,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
|||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
import FileImportModal from './FileImportModal'
|
import FileImportModal from './FileImportModal'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
|
import Tooltip from '../shared/Tooltip'
|
||||||
|
|
||||||
interface PlacesSidebarProps {
|
interface PlacesSidebarProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
@@ -33,6 +34,8 @@ 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 {
|
||||||
@@ -144,6 +147,7 @@ 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()
|
||||||
@@ -158,6 +162,12 @@ 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
|
||||||
@@ -372,74 +382,66 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
>
|
>
|
||||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
||||||
padding: '5px 10px', borderRadius: 8,
|
|
||||||
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
|
||||||
background: selectMode ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'none',
|
|
||||||
color: selectMode ? 'var(--accent)' : 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check size={11} strokeWidth={2} /> {t('common.select')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{selectMode && (
|
<div style={{ height: 1, background: 'var(--border-primary)', margin: '2px 0 10px' }} />
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, padding: '6px 8px', borderRadius: 8, background: 'var(--bg-tertiary)', fontSize: 11 }}>
|
|
||||||
<span style={{ flex: 1, color: 'var(--text-muted)', fontWeight: 500 }}>
|
|
||||||
{t('places.selectionCount', { count: selectedIds.size })}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedIds.size === filtered.length) {
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
} else {
|
|
||||||
setSelectedIds(new Set(filtered.map(p => p.id)))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4 }}
|
|
||||||
>
|
|
||||||
{selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedIds.size === 0) return
|
|
||||||
if (isMobile) {
|
|
||||||
setPendingDeleteIds(Array.from(selectedIds))
|
|
||||||
} else {
|
|
||||||
onBulkDeletePlaces?.(Array.from(selectedIds))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={selectedIds.size === 0}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none',
|
|
||||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default',
|
|
||||||
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
|
||||||
fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4, fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={11} strokeWidth={2} /> {t('places.deleteSelected')}
|
|
||||||
</button>
|
|
||||||
<button onClick={exitSelectMode} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 2 }}>
|
|
||||||
<X size={12} strokeWidth={2} color="var(--text-faint)" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
{/* Filter-Tabs */}
|
{/* Filter-Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
{(() => {
|
||||||
{([{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null] as const).filter(Boolean).map(f => (
|
const baseFiltered = places.filter(p => {
|
||||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }} style={{
|
if (categoryFilters.size > 0) {
|
||||||
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
if (p.category_id == null) {
|
||||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
if (!categoryFilters.has('uncategorized')) return false
|
||||||
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
|
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||||
color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
}
|
||||||
}}>{f.label}</button>
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
))}
|
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||||
</div>
|
return true
|
||||||
|
})
|
||||||
|
const counts = {
|
||||||
|
all: baseFiltered.length,
|
||||||
|
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
|
||||||
|
tracks: baseFiltered.filter(p => p.route_geometry).length,
|
||||||
|
}
|
||||||
|
const tabs = ([
|
||||||
|
{ id: 'all', label: t('places.all') },
|
||||||
|
{ id: 'unplanned', label: t('places.unplanned') },
|
||||||
|
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
|
||||||
|
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
{tabs.map(f => {
|
||||||
|
const active = filter === f.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }}
|
||||||
|
style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '4px 9px', borderRadius: 99,
|
||||||
|
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
|
||||||
|
background: active ? 'var(--accent)' : 'var(--bg-card)',
|
||||||
|
color: active ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||||
|
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||||
|
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 600, lineHeight: 1,
|
||||||
|
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
|
||||||
|
color: active ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||||
|
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{counts[f.id]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Suchfeld */}
|
{/* Suchfeld */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -470,9 +472,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
|
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
|
||||||
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 6, position: 'relative' }}>
|
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||||
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
@@ -480,6 +482,41 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
</button>
|
</button>
|
||||||
|
{canEditPlaces && (
|
||||||
|
<Tooltip label={t('common.select')} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||||
|
aria-label={t('common.select')}
|
||||||
|
aria-pressed={selectMode}
|
||||||
|
style={{
|
||||||
|
position: 'relative', width: 30, flexShrink: 0, borderRadius: 8,
|
||||||
|
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||||
|
background: selectMode ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
|
||||||
|
color: selectMode ? 'var(--accent)' : 'var(--text-faint)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||||
|
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
opacity: selectMode ? 0 : 1,
|
||||||
|
transform: selectMode ? 'rotate(-90deg) scale(0.6)' : 'rotate(0) scale(1)',
|
||||||
|
}}>
|
||||||
|
<Check size={13} strokeWidth={2.4} />
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
opacity: selectMode ? 1 : 0,
|
||||||
|
transform: selectMode ? 'rotate(0) scale(1)' : 'rotate(90deg) scale(0.6)',
|
||||||
|
}}>
|
||||||
|
<X size={13} strokeWidth={2.4} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{catDropOpen && (
|
{catDropOpen && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||||
@@ -550,13 +587,65 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Anzahl */}
|
{/* Anzahl / Auswahl-Leiste */}
|
||||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
{selectMode ? (
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
<div style={{
|
||||||
</div>
|
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 10%, transparent)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, fontSize: 11,
|
||||||
|
}}>
|
||||||
|
<span style={{ flex: 1, color: 'var(--accent)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{t('places.selectionCount', { count: selectedIds.size })}
|
||||||
|
</span>
|
||||||
|
<Tooltip label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedIds.size === filtered.length) setSelectedIds(new Set())
|
||||||
|
else setSelectedIds(new Set(filtered.map(p => p.id)))
|
||||||
|
}}
|
||||||
|
aria-label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||||
|
background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', padding: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Check size={13} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('places.deleteSelected')} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
if (isMobile) setPendingDeleteIds(Array.from(selectedIds))
|
||||||
|
else onBulkDeletePlaces?.(Array.from(selectedIds))
|
||||||
|
}}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
aria-label={t('places.deleteSelected')}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
||||||
|
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'color-mix(in srgb, #ef4444 14%, transparent)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Liste */}
|
{/* Liste */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
|
||||||
{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-035
|
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052
|
||||||
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,8 +203,10 @@ 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 submit the form directly
|
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
|
||||||
const form = screen.getByRole('button', { name: /^Add$/i }).closest('form')!;
|
// The Save button now lives in the Modal's sticky footer (outside the <form>), so we query
|
||||||
|
// 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();
|
||||||
@@ -721,4 +723,103 @@ 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,6 +143,18 @@ 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 = (() => {
|
||||||
@@ -170,6 +182,8 @@ 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
|
||||||
@@ -182,7 +196,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.assignment_id || null,
|
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (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: [],
|
||||||
@@ -193,9 +207,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_place_id && form.hotel_start_day && form.hotel_end_day) {
|
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||||
saveData.create_accommodation = {
|
saveData.create_accommodation = {
|
||||||
place_id: form.hotel_place_id,
|
place_id: form.hotel_place_id || null,
|
||||||
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,
|
||||||
@@ -259,7 +273,22 @@ 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 isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
|
<Modal
|
||||||
|
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 */}
|
||||||
@@ -405,12 +434,17 @@ 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)
|
||||||
if (p) {
|
setForm(prev => {
|
||||||
if (!form.title) set('title', p.name)
|
const next = { ...prev, hotel_place_id: value }
|
||||||
if (!form.location && p.address) set('location', p.address)
|
if (!value) {
|
||||||
}
|
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={[
|
||||||
@@ -425,9 +459,22 @@ 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 => set('hotel_start_day', value)}
|
onChange={value => setForm(prev => ({
|
||||||
|
...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 => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
options={days.map(d => {
|
||||||
|
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>
|
||||||
@@ -435,9 +482,22 @@ 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 => set('hotel_end_day', value)}
|
onChange={value => setForm(prev => ({
|
||||||
|
...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 => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
options={days.map(d => {
|
||||||
|
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>
|
||||||
@@ -589,15 +649,6 @@ 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,6 +11,9 @@ 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 {
|
||||||
@@ -112,17 +115,30 @@ 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 startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
const isHotel = r.type === 'hotel'
|
||||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
|
const startDay = r.day_id ? days.find(d => d.id === r.day_id)
|
||||||
const dayLabel = (day: typeof startDay): string => {
|
: (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id)
|
||||||
if (!day) return ''
|
: undefined
|
||||||
const base = day.title || t('dayplan.dayN', { n: day.day_number })
|
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id)
|
||||||
if (day.date) {
|
: (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id)
|
||||||
const d = new Date(day.date + 'T00:00:00Z')
|
: undefined
|
||||||
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const DayLabel = ({ day }: { day: typeof startDay }) => {
|
||||||
return `${base} · ${dateStr}`
|
if (!day) return null
|
||||||
}
|
const name = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||||
return base
|
const badge = day.date
|
||||||
|
? 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 (
|
||||||
@@ -135,13 +151,15 @@ 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 */}
|
{/* Header — wraps to a second row on narrow screens so the status/category chips
|
||||||
|
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 }}>
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 10, minWidth: 0, flexWrap: 'wrap' }}>
|
||||||
<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',
|
||||||
@@ -202,12 +220,15 @@ 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 reservations linked to a day */}
|
{/* Day label for transport/hotel reservations linked to days */}
|
||||||
{isTransportType && startDay && (
|
{(isTransportType || isHotel) && startDay && (
|
||||||
<div>
|
<div>
|
||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
<DayLabel day={startDay} />
|
||||||
|
{endDay && endDay.id !== startDay.id && (
|
||||||
|
<><span style={{ color: 'var(--text-faint)' }}>–</span><DayLabel day={endDay} /></>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -218,7 +239,16 @@ 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>
|
||||||
@@ -337,7 +367,9 @@ 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 style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
|
<div className="collab-note-md" style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||||
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
import { useParams } from 'react-router-dom'
|
||||||
|
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'
|
||||||
@@ -7,8 +8,12 @@ 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 type { Day, Reservation, ReservationEndpoint } from '../../types'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
|
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]
|
||||||
@@ -75,6 +80,8 @@ 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: '',
|
||||||
@@ -85,19 +92,36 @@ const defaultForm = {
|
|||||||
interface TransportModalProps {
|
interface TransportModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: Record<string, any>) => Promise<void>
|
onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
|
||||||
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 }: TransportModalProps) {
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: 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
|
||||||
@@ -126,6 +150,8 @@ 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 })
|
||||||
@@ -135,11 +161,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' })
|
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||||
setFromPick({})
|
setFromPick({})
|
||||||
setToPick({})
|
setToPick({})
|
||||||
}
|
}
|
||||||
}, [isOpen, reservation, selectedDayId])
|
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||||
|
|
||||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
@@ -173,6 +199,10 @@ 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
|
||||||
@@ -200,7 +230,21 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
endpoints,
|
endpoints,
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
await onSave(payload)
|
if (isBudgetEnabled) {
|
||||||
|
(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 {
|
||||||
@@ -208,6 +252,38 @@ 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',
|
||||||
@@ -220,10 +296,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
|
|
||||||
const dayOptions = [
|
const dayOptions = [
|
||||||
{ value: '', label: '—' },
|
{ value: '', label: '—' },
|
||||||
...days.map(d => ({
|
...days.map(d => {
|
||||||
value: d.id,
|
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
|
||||||
label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`,
|
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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -232,6 +313,16 @@ 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 }}>
|
||||||
|
|
||||||
@@ -407,15 +498,128 @@ 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>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Files */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
<div>
|
||||||
<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)' }}>
|
<label style={labelStyle}>{t('files.title')}</label>
|
||||||
{t('common.cancel')}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
</button>
|
{attachedFiles.map(f => (
|
||||||
<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 }}>
|
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
</button>
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
|||||||
href="https://ko-fi.com/mauriceboe"
|
href="https://ko-fi.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -272,7 +272,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
|||||||
href="https://buymeacoffee.com/mauriceboe"
|
href="https://buymeacoffee.com/mauriceboe"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -290,7 +290,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
|||||||
href="https://discord.gg/NhZBDSd4qW"
|
href="https://discord.gg/NhZBDSd4qW"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -311,7 +311,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
|||||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -329,7 +329,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
|||||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
@@ -347,7 +347,7 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
|||||||
href="https://github.com/mauriceboe/TREK/wiki"
|
href="https://github.com/mauriceboe/TREK/wiki"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe('DisplaySettingsTab', () => {
|
|||||||
|
|
||||||
it('FE-COMP-DISPLAY-005: shows Auto mode button', () => {
|
it('FE-COMP-DISPLAY-005: shows Auto mode button', () => {
|
||||||
render(<DisplaySettingsTab />);
|
render(<DisplaySettingsTab />);
|
||||||
expect(screen.getByText('Auto')).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-006: shows Language section', () => {
|
it('FE-COMP-DISPLAY-006: shows Language section', () => {
|
||||||
@@ -95,16 +95,16 @@ describe('DisplaySettingsTab', () => {
|
|||||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
|
||||||
render(<DisplaySettingsTab />);
|
render(<DisplaySettingsTab />);
|
||||||
await user.click(screen.getByText('Auto'));
|
await user.click(screen.getByRole('button', { name: /Auto/i }));
|
||||||
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
|
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => {
|
it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => {
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
|
||||||
render(<DisplaySettingsTab />);
|
render(<DisplaySettingsTab />);
|
||||||
const darkBtn = screen.getByText('Dark').closest('button')!;
|
const darkBtn = screen.getByRole('button', { name: /^Dark$/i });
|
||||||
const lightBtn = screen.getByText('Light').closest('button')!;
|
const lightBtn = screen.getByRole('button', { name: /^Light$/i });
|
||||||
const autoBtn = screen.getByText('Auto').closest('button')!;
|
const autoBtn = screen.getByRole('button', { name: /Auto/i });
|
||||||
expect(darkBtn.style.border).toContain('var(--text-primary)');
|
expect(darkBtn.style.border).toContain('var(--text-primary)');
|
||||||
expect(lightBtn.style.border).toContain('var(--border-primary)');
|
expect(lightBtn.style.border).toContain('var(--border-primary)');
|
||||||
expect(autoBtn.style.border).toContain('var(--border-primary)');
|
expect(autoBtn.style.border).toContain('var(--border-primary)');
|
||||||
@@ -122,8 +122,11 @@ describe('DisplaySettingsTab', () => {
|
|||||||
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
|
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
|
||||||
render(<DisplaySettingsTab />);
|
render(<DisplaySettingsTab />);
|
||||||
const englishBtn = screen.getByText('English').closest('button')!;
|
// Multiple elements contain "English" (desktop grid button + mobile dropdown trigger).
|
||||||
expect(englishBtn.style.border).toContain('var(--text-primary)');
|
// The desktop grid button is the one with the active border style.
|
||||||
|
const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean);
|
||||||
|
const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)'));
|
||||||
|
expect(activeBtn).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
|
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
|
||||||
@@ -152,7 +155,9 @@ 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 />);
|
||||||
await user.click(screen.getByText('24h (14:30)'));
|
// The label is split across a text node ('24h') and a responsive span (' (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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { Palette, Sun, Moon, Monitor } from 'lucide-react'
|
import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react'
|
||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
@@ -10,6 +10,17 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||||
|
const [langOpen, setLangOpen] = useState(false)
|
||||||
|
const langDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!langOpen) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [langOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTempUnit(settings.temperature_unit || 'celsius')
|
setTempUnit(settings.temperature_unit || 'celsius')
|
||||||
@@ -46,8 +57,13 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<opt.icon size={16} />
|
<span className="hidden sm:inline-flex"><opt.icon size={16} /></span>
|
||||||
{opt.label}
|
{opt.value === 'auto' ? (
|
||||||
|
<>
|
||||||
|
<span className="hidden sm:inline">{opt.label}</span>
|
||||||
|
<span className="sm:hidden">Auto</span>
|
||||||
|
</>
|
||||||
|
) : opt.label}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -57,7 +73,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
{/* Language */}
|
{/* Language */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
|
||||||
<div className="flex flex-wrap gap-3">
|
{/* Desktop: Button grid */}
|
||||||
|
<div className="hidden sm:flex flex-wrap gap-3">
|
||||||
{SUPPORTED_LANGUAGES.map(opt => (
|
{SUPPORTED_LANGUAGES.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -79,6 +96,60 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mobile: Custom dropdown */}
|
||||||
|
<div ref={langDropdownRef} className="sm:hidden" style={{ position: 'relative' }}>
|
||||||
|
{(() => {
|
||||||
|
const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLangOpen(v => !v)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '10px 14px', borderRadius: 10,
|
||||||
|
border: '2px solid var(--border-primary)',
|
||||||
|
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||||
|
fontSize: 14, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{current?.label}</span>
|
||||||
|
<ChevronDown size={14} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: langOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{langOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 'calc(100% + 4px)', left: 0, right: 0, zIndex: 50,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.15)', padding: 4, maxHeight: 280, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{SUPPORTED_LANGUAGES.map(opt => {
|
||||||
|
const active = settings.language === opt.value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
setLangOpen(false)
|
||||||
|
try { await updateSetting('language', opt.value) }
|
||||||
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
fontFamily: 'inherit', fontSize: 14, color: 'var(--text-primary)',
|
||||||
|
textAlign: 'left', fontWeight: active ? 600 : 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>{opt.label}</span>
|
||||||
|
{active && <Check size={14} strokeWidth={2.5} color="var(--accent)" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Temperature */}
|
{/* Temperature */}
|
||||||
@@ -117,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', label: '24h (14:30)' },
|
{ value: '24h', short: '24h', example: '14:30' },
|
||||||
{ value: '12h', label: '12h (2:30 PM)' },
|
{ value: '12h', short: '12h', example: '2:30 PM' },
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -136,7 +207,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.short}
|
||||||
|
<span className="hidden sm:inline">{` (${opt.example})`}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,12 +123,12 @@ describe('MapSettingsTab', () => {
|
|||||||
});
|
});
|
||||||
render(<MapSettingsTab />);
|
render(<MapSettingsTab />);
|
||||||
await user.click(screen.getByText('Save Map'));
|
await user.click(screen.getByText('Save Map'));
|
||||||
expect(updateSettings).toHaveBeenCalledWith({
|
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
map_tile_url: '',
|
map_tile_url: '',
|
||||||
default_lat: 48.8566,
|
default_lat: 48.8566,
|
||||||
default_lng: 2.3522,
|
default_lng: 2.3522,
|
||||||
default_zoom: 10,
|
default_zoom: 10,
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-MAP-013: Save Map button shows spinner while saving', async () => {
|
it('FE-COMP-MAP-013: Save Map button shows spinner while saving', async () => {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { Map, Save } from 'lucide-react'
|
import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { MapView } from '../Map/MapView'
|
import { MapView } from '../Map/MapView'
|
||||||
|
import MapboxPreview from './MapboxPreview'
|
||||||
import Section from './Section'
|
import Section from './Section'
|
||||||
|
import ToggleSwitch from './ToggleSwitch'
|
||||||
import type { Place } from '../../types'
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
interface MapPreset {
|
interface MapPreset {
|
||||||
@@ -21,18 +23,137 @@ const MAP_PRESETS: MapPreset[] = [
|
|||||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
interface StylePreset {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAPBOX_STYLE_PRESETS: StylePreset[] = [
|
||||||
|
{ name: 'Mapbox Standard', url: 'mapbox://styles/mapbox/standard', tags: ['3D', 'Apple-like'] },
|
||||||
|
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
|
||||||
|
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
|
||||||
|
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
|
||||||
|
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
|
||||||
|
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
|
||||||
|
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
|
||||||
|
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
|
||||||
|
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
|
||||||
|
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
|
||||||
|
// user scanning the list can spot 3D / Satellite / Apple-like styles.
|
||||||
|
const TAG_STYLES: Record<string, string> = {
|
||||||
|
'3D': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||||
|
'2D': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||||
|
'Satellite': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
|
'Apple-like': 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
|
||||||
|
'Modern': 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
|
'Dark': 'bg-zinc-800 text-zinc-100 dark:bg-zinc-900 dark:text-zinc-300',
|
||||||
|
'Minimal': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||||
|
'Hillshading': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
'Terrain': 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300',
|
||||||
|
'Realistic': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
|
||||||
|
'Navigation': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
|
||||||
|
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||||
|
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagChip({ tag }: { tag: string }) {
|
||||||
|
const cls = TAG_STYLES[tag] || 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
|
||||||
|
return (
|
||||||
|
<span className={`text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded leading-none ${cls}`}>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDoc)
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg text-sm bg-white dark:bg-slate-900 hover:border-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-slate-900 dark:text-white truncate">
|
||||||
|
{selected ? selected.name : t('settings.mapStylePlaceholder')}
|
||||||
|
</span>
|
||||||
|
{selected && (
|
||||||
|
<span className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{selected.tags.map(t => <TagChip key={t} tag={t} />)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={14} className="flex-shrink-0 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
|
||||||
|
{MAPBOX_STYLE_PRESETS.map(preset => {
|
||||||
|
const isActive = preset.url === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.url}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onChange(preset.url); setOpen(false) }}
|
||||||
|
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-800 ${isActive ? 'bg-slate-50 dark:bg-slate-800' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
|
||||||
|
{preset.tags.map(t => <TagChip key={t} tag={t} />)}
|
||||||
|
</span>
|
||||||
|
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Provider = 'leaflet' | 'mapbox-gl'
|
||||||
|
|
||||||
export default function MapSettingsTab(): React.ReactElement {
|
export default function MapSettingsTab(): React.ReactElement {
|
||||||
const { settings, updateSettings } = useSettingsStore()
|
const { settings, updateSettings } = useSettingsStore()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [provider, setProvider] = useState<Provider>((settings.map_provider as Provider) || 'leaflet')
|
||||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||||
|
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||||
|
const [mapboxStyle, setMapboxStyle] = useState<string>(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
|
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||||
|
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
||||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setProvider((settings.map_provider as Provider) || 'leaflet')
|
||||||
setMapTileUrl(settings.map_tile_url || '')
|
setMapTileUrl(settings.map_tile_url || '')
|
||||||
|
setMapboxToken(settings.mapbox_access_token || '')
|
||||||
|
setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
|
setMapbox3d(settings.mapbox_3d_enabled !== false)
|
||||||
|
setMapboxQuality(settings.mapbox_quality_mode === true)
|
||||||
setDefaultLat(settings.default_lat || 48.8566)
|
setDefaultLat(settings.default_lat || 48.8566)
|
||||||
setDefaultLng(settings.default_lng || 2.3522)
|
setDefaultLng(settings.default_lng || 2.3522)
|
||||||
setDefaultZoom(settings.default_zoom || 10)
|
setDefaultZoom(settings.default_zoom || 10)
|
||||||
@@ -67,7 +188,12 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await updateSettings({
|
await updateSettings({
|
||||||
|
map_provider: provider,
|
||||||
map_tile_url: mapTileUrl,
|
map_tile_url: mapTileUrl,
|
||||||
|
mapbox_access_token: mapboxToken,
|
||||||
|
mapbox_style: mapboxStyle,
|
||||||
|
mapbox_3d_enabled: mapbox3d,
|
||||||
|
mapbox_quality_mode: mapboxQuality,
|
||||||
default_lat: parseFloat(String(defaultLat)),
|
default_lat: parseFloat(String(defaultLat)),
|
||||||
default_lng: parseFloat(String(defaultLng)),
|
default_lng: parseFloat(String(defaultLng)),
|
||||||
default_zoom: parseInt(String(defaultZoom)),
|
default_zoom: parseInt(String(defaultZoom)),
|
||||||
@@ -80,28 +206,159 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3D is available on every style now — pure satellite uses the
|
||||||
|
// mapbox-streets-v8 tileset as a fallback building source.
|
||||||
|
const supports3d = true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t('settings.map')} icon={Map}>
|
<Section title={t('settings.map')} icon={Map}>
|
||||||
|
{/* Provider picker — big cards so the choice is obvious */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
|
||||||
<CustomSelect
|
<div className="grid grid-cols-2 gap-2">
|
||||||
value={mapTileUrl}
|
<button
|
||||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
type="button"
|
||||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
onClick={() => setProvider('leaflet')}
|
||||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||||
size="sm"
|
provider === 'leaflet'
|
||||||
style={{ marginBottom: 8 }}
|
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||||
/>
|
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||||
<input
|
}`}
|
||||||
type="text"
|
>
|
||||||
value={mapTileUrl}
|
<Layers size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
<div>
|
||||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
<div className="text-sm font-medium text-slate-900 dark:text-white">Leaflet</div>
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapLeafletSubtitle')}</div>
|
||||||
/>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setProvider('mapbox-gl')}
|
||||||
|
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||||
|
provider === 'mapbox-gl'
|
||||||
|
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||||
|
: '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" />
|
||||||
|
<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')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-2">
|
||||||
|
{t('settings.mapProviderHint')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Leaflet settings */}
|
||||||
|
{provider === 'leaflet' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={mapTileUrl}
|
||||||
|
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||||
|
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||||
|
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||||
|
size="sm"
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mapTileUrl}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||||
|
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mapbox GL settings */}
|
||||||
|
{provider === 'mapbox-gl' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapMapboxToken')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mapboxToken}
|
||||||
|
onChange={(e) => setMapboxToken(e.target.value)}
|
||||||
|
placeholder="pk.eyJ1Ijoi..."
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{t('settings.mapMapboxTokenHint')}{' '}
|
||||||
|
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className="underline">
|
||||||
|
{t('settings.mapMapboxTokenLink')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapStyle')}</label>
|
||||||
|
<div className="mb-2">
|
||||||
|
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mapboxStyle}
|
||||||
|
onChange={(e) => setMapboxStyle(e.target.value)}
|
||||||
|
placeholder="mapbox://styles/mapbox/standard"
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{t('settings.mapStyleHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||||
|
supports3d
|
||||||
|
? 'border-slate-200 dark:border-slate-700'
|
||||||
|
: 'border-slate-200 opacity-60 dark:border-slate-700'
|
||||||
|
}`}>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-slate-900 dark:text-white">{t('settings.map3dBuildings')}</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{t('settings.map3dHint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
on={mapbox3d && supports3d}
|
||||||
|
onToggle={() => { if (supports3d) setMapbox3d(!mapbox3d) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-slate-900 dark:text-white flex flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||||
|
<span className="order-2 sm:order-1">{t('settings.mapHighQuality')}</span>
|
||||||
|
<span className="order-1 sm:order-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')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{t('settings.mapHighQualityHint')}{' '}
|
||||||
|
<span className="text-amber-600 dark:text-amber-400">{t('settings.mapHighQualityWarning')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||||
|
<strong className="text-slate-600 dark:text-slate-300">{t('settings.mapTipLabel')}</strong> {t('settings.mapTip')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default map position — applies regardless of provider */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
||||||
@@ -109,7 +366,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={defaultLat}
|
value={defaultLat}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
|
onChange={(e) => setDefaultLat(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +376,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={defaultLng}
|
value={defaultLng}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
|
onChange={(e) => setDefaultLng(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,25 +384,40 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{provider === 'mapbox-gl' ? (
|
||||||
{React.createElement(MapView as any, {
|
<MapboxPreview
|
||||||
places: mapPlaces,
|
token={mapboxToken}
|
||||||
dayPlaces: [],
|
style={mapboxStyle}
|
||||||
route: null,
|
lat={parseFloat(String(defaultLat)) || 48.8566}
|
||||||
routeSegments: null,
|
lng={parseFloat(String(defaultLng)) || 2.3522}
|
||||||
selectedPlaceId: null,
|
// Zoom in close so the style's character (3D buildings,
|
||||||
onMarkerClick: null,
|
// satellite texture, label density) is immediately visible.
|
||||||
onMapClick: handleMapClick,
|
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)}
|
||||||
onMapContextMenu: null,
|
enable3d={mapbox3d && supports3d}
|
||||||
center: [settings.default_lat, settings.default_lng],
|
quality={mapboxQuality}
|
||||||
zoom: defaultZoom,
|
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
|
||||||
tileUrl: mapTileUrl,
|
/>
|
||||||
fitKey: null,
|
) : (
|
||||||
dayOrderMap: [],
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
leftWidth: 0,
|
React.createElement(MapView as any, {
|
||||||
rightWidth: 0,
|
places: mapPlaces,
|
||||||
hasInspector: false,
|
dayPlaces: [],
|
||||||
})}
|
route: null,
|
||||||
|
routeSegments: null,
|
||||||
|
selectedPlaceId: null,
|
||||||
|
onMarkerClick: null,
|
||||||
|
onMapClick: handleMapClick,
|
||||||
|
onMapContextMenu: null,
|
||||||
|
center: [settings.default_lat, settings.default_lng],
|
||||||
|
zoom: defaultZoom,
|
||||||
|
tileUrl: mapTileUrl,
|
||||||
|
fitKey: null,
|
||||||
|
dayOrderMap: [],
|
||||||
|
leftWidth: 0,
|
||||||
|
rightWidth: 0,
|
||||||
|
hasInspector: false,
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import mapboxgl from 'mapbox-gl'
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||||
|
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string
|
||||||
|
style: string
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
zoom: number
|
||||||
|
enable3d: boolean
|
||||||
|
quality?: boolean
|
||||||
|
onClick?: (latlng: { lat: number; lng: number }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
|
const onClickRef = useRef(onClick)
|
||||||
|
onClickRef.current = onClick
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !token) return
|
||||||
|
mapboxgl.accessToken = token
|
||||||
|
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style,
|
||||||
|
center: [lng, lat],
|
||||||
|
zoom,
|
||||||
|
pitch: enable3d ? 45 : 0,
|
||||||
|
attributionControl: true,
|
||||||
|
antialias: quality,
|
||||||
|
projection: quality ? 'globe' : 'mercator',
|
||||||
|
})
|
||||||
|
mapRef.current = map
|
||||||
|
|
||||||
|
map.on('load', () => {
|
||||||
|
if (enable3d) {
|
||||||
|
if (!isStandardFamily(style)) addTerrainAndSky(map)
|
||||||
|
if (supportsCustom3d(style)) {
|
||||||
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
|
addCustom3dBuildings(map, dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (style === 'mapbox://styles/mapbox/standard') {
|
||||||
|
try { map.setTerrain(null) } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on('click', (e) => {
|
||||||
|
onClickRef.current?.({ lat: e.lngLat.lat, lng: e.lngLat.lng })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try { map.remove() } catch { /* noop */ }
|
||||||
|
mapRef.current = null
|
||||||
|
}
|
||||||
|
}, [token, style, enable3d, quality])
|
||||||
|
|
||||||
|
// Recenter without rebuilding the map when lat/lng/zoom change externally
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current) return
|
||||||
|
try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ }
|
||||||
|
}, [lat, lng, zoom])
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
Enter a Mapbox access token to preview
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div ref={containerRef} style={{ width: '100%', height: '100%', borderRadius: '8px', overflow: 'hidden' }} />
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ 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,6 +5,7 @@ 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
|
||||||
@@ -222,15 +223,13 @@ 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' ? (
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<ToggleSwitch
|
||||||
type="checkbox"
|
on={values[field.key] === 'true'}
|
||||||
checked={values[field.key] === 'true'}
|
onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : '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>
|
||||||
</label>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<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>
|
||||||
@@ -248,7 +247,9 @@ export default function PhotoProvidersSection(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center gap-3">
|
{/* Wraps on mobile so the connection badge drops to its own row
|
||||||
|
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)}
|
||||||
@@ -266,15 +267,17 @@ 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" />}
|
||||||
{t('memories.testConnection')}
|
<span className="sm:hidden">{t('memories.testShort')}</span>
|
||||||
|
<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="text-xs font-medium text-green-600 flex items-center gap-1">
|
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
{t('memories.connected')}
|
{t('memories.connected')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs font-medium text-slate-400 flex items-center gap-1">
|
<span className="basis-full sm:basis-auto 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,9 +2,10 @@ 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 onClick={onToggle}
|
<button type="button" onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', width: 44, height: 24, borderRadius: 12, border: 'none', cursor: 'pointer',
|
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||||
|
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',
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function VacayCalendar() {
|
|||||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCompanyMode(false)}
|
onClick={() => setCompanyMode(false)}
|
||||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{
|
style={{
|
||||||
background: !companyMode ? 'var(--text-primary)' : 'transparent',
|
background: !companyMode ? 'var(--text-primary)' : 'transparent',
|
||||||
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
|
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||||
@@ -107,7 +107,7 @@ export default function VacayCalendar() {
|
|||||||
{companyHolidaysEnabled && (
|
{companyHolidaysEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setCompanyMode(true)}
|
onClick={() => setCompanyMode(true)}
|
||||||
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-[background-color,color,border-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
style={{
|
style={{
|
||||||
background: companyMode ? '#d97706' : 'transparent',
|
background: companyMode ? '#d97706' : 'transparent',
|
||||||
color: companyMode ? '#fff' : 'var(--text-muted)',
|
color: companyMode ? '#fff' : 'var(--text-muted)',
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ export default function VacayPersons() {
|
|||||||
|
|
||||||
{/* Invite Modal — Portal to body to avoid z-index issues */}
|
{/* Invite Modal — Portal to body to avoid z-index issues */}
|
||||||
{showInvite && ReactDOM.createPortal(
|
{showInvite && ReactDOM.createPortal(
|
||||||
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||||
onClick={() => setShowInvite(false)}>
|
onClick={() => setShowInvite(false)}>
|
||||||
<div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)' }}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
|
||||||
@@ -164,9 +164,9 @@ export default function VacayPersons() {
|
|||||||
|
|
||||||
{/* Color Picker Modal — Portal to body */}
|
{/* Color Picker Modal — Portal to body */}
|
||||||
{showColorPicker && ReactDOM.createPortal(
|
{showColorPicker && ReactDOM.createPortal(
|
||||||
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
<div className="fixed inset-0 flex items-center justify-center px-4 trek-backdrop-enter" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||||
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
|
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
|
||||||
<div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
<div className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)' }}
|
||||||
onClick={e => e.stopPropagation()}>
|
onClick={e => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
|
||||||
@@ -178,7 +178,7 @@ export default function VacayPersons() {
|
|||||||
<div className="flex flex-wrap gap-2 justify-center">
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
{PRESET_COLORS.map(c => (
|
{PRESET_COLORS.map(c => (
|
||||||
<button key={c} onClick={() => handleColorChange(c)}
|
<button key={c} onClick={() => handleColorChange(c)}
|
||||||
className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
|
className={`w-8 h-8 rounded-full transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
|
||||||
style={{ backgroundColor: c }} />
|
style={{ backgroundColor: c }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP
|
|||||||
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
|
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
|
<div
|
||||||
|
className="trek-bar-fill h-full rounded-full transition-[width] duration-500 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||||
|
style={{ width: `${pct}%`, backgroundColor: s.person_color }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
{/* Days — editable */}
|
{/* Days — editable */}
|
||||||
|
|||||||
@@ -40,16 +40,13 @@ export default function ConfirmDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4"
|
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
className="trek-modal-enter rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
||||||
style={{
|
style={{ background: 'var(--bg-card)' }}
|
||||||
animation: 'modalIn 0.2s ease-out forwards',
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
@@ -90,12 +87,6 @@ export default function ConfirmDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes modalIn {
|
|
||||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
|
||||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
|||||||
if (!menu) return null
|
if (!menu) return null
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div ref={ref} style={{
|
<div ref={ref} className="trek-popover-enter" style={{
|
||||||
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||||
border: '1px solid var(--border-primary)',
|
border: '1px solid var(--border-primary)',
|
||||||
@@ -73,7 +73,7 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
|||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
animation: 'ctxIn 0.1s ease-out',
|
transformOrigin: 'top left',
|
||||||
}}>
|
}}>
|
||||||
{menu.items.filter(Boolean).map((item, i) => {
|
{menu.items.filter(Boolean).map((item, i) => {
|
||||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
||||||
@@ -95,7 +95,6 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
|
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React, { useCallback, useState } from 'react'
|
||||||
|
import { Copy, Check } from 'lucide-react'
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
value: string
|
||||||
|
size?: number
|
||||||
|
title?: string
|
||||||
|
className?: string
|
||||||
|
onCopy?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button that morphs between copy icon and check icon for 1.5s after click.
|
||||||
|
export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
setCopied(true)
|
||||||
|
onCopy?.()
|
||||||
|
window.setTimeout(() => setCopied(false), 1500)
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}, [value, onCopy])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
title={title}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: size + 12,
|
||||||
|
height: size + 12,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: copied ? '#22c55e' : 'var(--text-muted)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={size} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
|
opacity: copied ? 0 : 1,
|
||||||
|
transform: copied ? 'scale(0.6) rotate(-45deg)' : 'scale(1) rotate(0)',
|
||||||
|
}} />
|
||||||
|
<Check size={size} style={{
|
||||||
|
position: 'absolute',
|
||||||
|
transition: 'opacity 200ms cubic-bezier(0.23,1,0.32,1), transform 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
|
opacity: copied ? 1 : 0,
|
||||||
|
transform: copied ? 'scale(1) rotate(0)' : 'scale(0.6) rotate(45deg)',
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
}} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CopyButton
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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,13 +119,14 @@ 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
|
const w = 268, pad = 8, h = 360
|
||||||
const vw = window.innerWidth
|
const vw = window.innerWidth
|
||||||
const vh = window.innerHeight
|
const vh = window.visualViewport?.height ?? 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 + 320 > vh) top = Math.max(pad, r.top - 320)
|
if (top + h > vh - pad) top = r.top - h - 4
|
||||||
|
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,6 +9,7 @@ interface SelectOption {
|
|||||||
isHeader?: boolean
|
isHeader?: boolean
|
||||||
searchLabel?: string
|
searchLabel?: string
|
||||||
groupLabel?: string
|
groupLabel?: string
|
||||||
|
badge?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomSelectProps {
|
interface CustomSelectProps {
|
||||||
@@ -104,7 +105,14 @@ 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>
|
||||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
|
{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' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown */}
|
{/* Dropdown */}
|
||||||
@@ -128,7 +136,9 @@ export default function CustomSelect({
|
|||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
animation: 'selectIn 0.15s ease-out',
|
animation: 'trek-menu-enter 200ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
willChange: 'transform, opacity',
|
||||||
}}>
|
}}>
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
{searchable && (
|
{searchable && (
|
||||||
@@ -184,6 +194,13 @@ 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>
|
||||||
)
|
)
|
||||||
@@ -194,12 +211,6 @@ export default function CustomSelect({
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes selectIn {
|
|
||||||
from { opacity: 0; transform: translateY(-4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useState, type ImgHTMLAttributes } from 'react'
|
||||||
|
|
||||||
|
interface LoadingImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||||
|
containerClassName?: string
|
||||||
|
containerStyle?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires.
|
||||||
|
export function LoadingImage({
|
||||||
|
containerClassName, containerStyle, className, style, onLoad, ...imgProps
|
||||||
|
}: LoadingImageProps): React.ReactElement {
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className={containerClassName} style={{ position: 'relative', overflow: 'hidden', ...containerStyle }}>
|
||||||
|
{!loaded && (
|
||||||
|
<div
|
||||||
|
className="trek-skeleton"
|
||||||
|
style={{ position: 'absolute', inset: 0, borderRadius: 0 }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
{...imgProps}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
opacity: loaded ? 1 : 0,
|
||||||
|
transition: 'opacity 300ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||||
|
}}
|
||||||
|
onLoad={e => { setLoaded(true); onLoad?.(e) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingImage
|
||||||
@@ -50,7 +50,7 @@ export default function Modal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop"
|
className="fixed inset-0 z-[200] flex items-start sm:items-center justify-center px-4 modal-backdrop trek-backdrop-enter"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@@ -60,18 +60,16 @@ export default function Modal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
trek-modal-enter
|
||||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||||
animate-in fade-in zoom-in-95 duration-200
|
flex flex-col
|
||||||
|
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{ background: 'var(--bg-card)' }}
|
||||||
animation: 'modalIn 0.2s ease-out forwards',
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
}}
|
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — stays put even while the body scrolls */}
|
||||||
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
<div className="flex items-center justify-between p-6 flex-shrink-0" 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
|
||||||
@@ -83,25 +81,19 @@ export default function Modal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body — scrolls when content overflows. min-h-0 lets the flex child shrink below its intrinsic height. */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6 min-h-0">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer — sticky at the bottom of the modal, never compressed */}
|
||||||
{footer && (
|
{footer && (
|
||||||
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
<div className="p-6 flex-shrink-0" style={{ borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes modalIn {
|
|
||||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
|
||||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
// Simple skeleton placeholder with shimmer. Size via className or props.
|
||||||
|
export function Skeleton({
|
||||||
|
width, height, radius, className, style,
|
||||||
|
}: {
|
||||||
|
width?: number | string
|
||||||
|
height?: number | string
|
||||||
|
radius?: number | string
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`trek-skeleton ${className ?? ''}`.trim()}
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height: height ?? 14,
|
||||||
|
borderRadius: radius,
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trip card skeleton matching SpotlightCard layout
|
||||||
|
export function SpotlightSkeleton(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative rounded-3xl overflow-hidden mb-8"
|
||||||
|
style={{ minHeight: 340, background: 'var(--bg-tertiary)' }}
|
||||||
|
>
|
||||||
|
<div className="trek-skeleton absolute inset-0" style={{ borderRadius: 24 }} />
|
||||||
|
<div className="relative p-6 flex flex-col justify-end" style={{ minHeight: 340 }}>
|
||||||
|
<Skeleton width={160} height={40} radius={8} style={{ marginBottom: 8 }} />
|
||||||
|
<Skeleton width={220} height={16} radius={4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trip list item skeleton
|
||||||
|
export function TripCardSkeleton(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-zinc-200 dark:border-zinc-700 overflow-hidden"
|
||||||
|
style={{ background: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<Skeleton height={140} radius={0} />
|
||||||
|
<div className="p-4 flex flex-col gap-2">
|
||||||
|
<Skeleton width="60%" height={18} />
|
||||||
|
<Skeleton width="40%" height={12} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day sidebar skeleton row
|
||||||
|
export function DaySkeleton(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<Skeleton width={120} height={16} />
|
||||||
|
<Skeleton width="80%" height={12} />
|
||||||
|
<Skeleton width="60%" height={12} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Skeleton
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||||
|
|
||||||
|
export interface SlidingTab<T extends string> {
|
||||||
|
id: T
|
||||||
|
label: React.ReactNode
|
||||||
|
title?: string
|
||||||
|
icon?: React.ComponentType<{ size?: number; className?: string }>
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlidingTabsProps<T extends string> {
|
||||||
|
tabs: readonly SlidingTab<T>[]
|
||||||
|
activeTab: T
|
||||||
|
onChange: (id: T) => void
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
fullWidth?: boolean
|
||||||
|
className?: string
|
||||||
|
indicatorColor?: string
|
||||||
|
indicatorTextColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stripe-style sliding indicator — der aktive Pill gleitet zwischen Tabs.
|
||||||
|
// Nutzt gemessene Offsets der Buttons + CSS transform.
|
||||||
|
export function SlidingTabs<T extends string>({
|
||||||
|
tabs, activeTab, onChange, size = 'md', fullWidth, className,
|
||||||
|
indicatorColor = 'var(--accent)', indicatorTextColor = 'var(--accent-text)',
|
||||||
|
}: SlidingTabsProps<T>): React.ReactElement {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const tabRefs = useRef<Map<T, HTMLButtonElement | null>>(new Map())
|
||||||
|
const [indicator, setIndicator] = useState<{ left: number; width: number; ready: boolean }>({ left: 0, width: 0, ready: false })
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const active = tabRefs.current.get(activeTab)
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!active || !container) return
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
const activeRect = active.getBoundingClientRect()
|
||||||
|
setIndicator({
|
||||||
|
left: activeRect.left - containerRect.left + container.scrollLeft,
|
||||||
|
width: activeRect.width,
|
||||||
|
ready: true,
|
||||||
|
})
|
||||||
|
}, [activeTab, tabs.length])
|
||||||
|
|
||||||
|
const padding = size === 'sm' ? '5px 12px' : '6px 14px'
|
||||||
|
const fontSize = size === 'sm' ? 12 : 13
|
||||||
|
const borderRadius = size === 'sm' ? 18 : 20
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
position: 'relative', display: 'flex', alignItems: 'center',
|
||||||
|
gap: 2, overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
|
||||||
|
width: fullWidth ? '100%' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sliding indicator */}
|
||||||
|
{indicator.ready && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: indicator.left,
|
||||||
|
width: indicator.width,
|
||||||
|
height: size === 'sm' ? 26 : 30,
|
||||||
|
background: indicatorColor,
|
||||||
|
borderRadius,
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
transition: 'left 320ms cubic-bezier(0.77, 0, 0.175, 1), width 320ms cubic-bezier(0.77, 0, 0.175, 1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
willChange: 'left, width',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tabs.map(tab => {
|
||||||
|
const isActive = tab.id === activeTab
|
||||||
|
const Icon = tab.icon
|
||||||
|
const btnStyle: CSSProperties = {
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
padding,
|
||||||
|
borderRadius,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize,
|
||||||
|
fontWeight: isActive ? 600 : 500,
|
||||||
|
background: 'transparent',
|
||||||
|
color: isActive ? indicatorTextColor : 'var(--text-muted)',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
transition: 'color 220ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
flex: fullWidth ? 1 : undefined,
|
||||||
|
justifyContent: 'center',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
ref={el => { tabRefs.current.set(tab.id, el) }}
|
||||||
|
onClick={() => onChange(tab.id)}
|
||||||
|
style={btnStyle}
|
||||||
|
title={tab.title ?? (typeof tab.label === 'string' ? tab.label : undefined)}
|
||||||
|
>
|
||||||
|
{Icon && <Icon size={size === 'sm' ? 13 : 15} />}
|
||||||
|
{tab.label}
|
||||||
|
{tab.count != null && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 600,
|
||||||
|
padding: '1px 6px', borderRadius: 99, minWidth: 16,
|
||||||
|
background: isActive ? 'rgba(255,255,255,0.22)' : 'var(--bg-tertiary)',
|
||||||
|
color: isActive ? 'inherit' : 'var(--text-faint)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>{tab.count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SlidingTabs
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
|
||||||
|
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
label: string
|
||||||
|
placement?: Placement
|
||||||
|
delay?: number
|
||||||
|
disabled?: boolean
|
||||||
|
children: React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(null)
|
||||||
|
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const timerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
if (disabled || !label) return
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = window.setTimeout(() => setOpen(true), delay)
|
||||||
|
}
|
||||||
|
const hide = () => {
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !triggerRef.current) return
|
||||||
|
const r = triggerRef.current.getBoundingClientRect()
|
||||||
|
const tipW = tooltipRef.current?.offsetWidth ?? 0
|
||||||
|
const tipH = tooltipRef.current?.offsetHeight ?? 0
|
||||||
|
const gap = 6
|
||||||
|
let top = 0, left = 0
|
||||||
|
if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
|
||||||
|
else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
|
||||||
|
else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
|
||||||
|
else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
|
||||||
|
const pad = 6
|
||||||
|
left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
|
||||||
|
top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
|
||||||
|
setCoords({ top, left })
|
||||||
|
}, [open, placement, label])
|
||||||
|
|
||||||
|
const child = React.Children.only(children)
|
||||||
|
const trigger = React.cloneElement(child, {
|
||||||
|
ref: (node: HTMLElement | null) => {
|
||||||
|
triggerRef.current = node
|
||||||
|
const r = (child as any).ref
|
||||||
|
if (typeof r === 'function') r(node)
|
||||||
|
else if (r && typeof r === 'object') r.current = node
|
||||||
|
},
|
||||||
|
onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) },
|
||||||
|
onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) },
|
||||||
|
onFocus: (e: any) => { show(); child.props.onFocus?.(e) },
|
||||||
|
onBlur: (e: any) => { hide(); child.props.onBlur?.(e) },
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trigger}
|
||||||
|
{open && ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
role="tooltip"
|
||||||
|
className="trek-popover-enter"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: coords?.top ?? -9999,
|
||||||
|
left: coords?.left ?? -9999,
|
||||||
|
visibility: coords ? 'visible' : 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 100000,
|
||||||
|
background: 'var(--bg-card, #ffffff)',
|
||||||
|
color: 'var(--text-primary, #111827)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '5px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
transformOrigin: placement === 'top' ? 'bottom center' : placement === 'bottom' ? 'top center' : placement === 'left' ? 'center right' : 'center left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tooltip
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
const isTestEnv = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
|
||||||
|
|
||||||
|
// Zählt beim Mount von 0 auf target hoch. Feste Dauer mit ease-out-quint.
|
||||||
|
export function useCountUp(target: number, duration = 800): number {
|
||||||
|
const [value, setValue] = useState(() => isTestEnv || target <= 0 ? target : 0)
|
||||||
|
const startRef = useRef<number | null>(null)
|
||||||
|
const frameRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
|
||||||
|
if (reduced || isTestEnv || target <= 0) { setValue(target); return }
|
||||||
|
|
||||||
|
startRef.current = null
|
||||||
|
const step = (now: number) => {
|
||||||
|
if (startRef.current == null) startRef.current = now
|
||||||
|
const elapsed = now - startRef.current
|
||||||
|
const t = Math.min(elapsed / duration, 1)
|
||||||
|
// ease-out-quint
|
||||||
|
const eased = 1 - Math.pow(1 - t, 5)
|
||||||
|
setValue(Math.round(target * eased))
|
||||||
|
if (t < 1) frameRef.current = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
frameRef.current = requestAnimationFrame(step)
|
||||||
|
return () => { if (frameRef.current) cancelAnimationFrame(frameRef.current) }
|
||||||
|
}, [target, duration])
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Permission-gated orientation listener with iOS support. iOS 13+ requires
|
||||||
|
// an explicit user gesture to request permission, so the caller triggers
|
||||||
|
// this from the "enable location" button click.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type DeviceOrientationEventIOS = typeof DeviceOrientationEvent & { requestPermission?: () => Promise<'granted' | 'denied'> }
|
||||||
|
|
||||||
|
export interface GeoPosition {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
accuracy: number // meters
|
||||||
|
heading: number | null // 0-360°, null when unavailable (stationary, indoor, no sensor)
|
||||||
|
speed: number | null
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackingMode = 'off' | 'show' | 'follow'
|
||||||
|
|
||||||
|
export interface UseGeolocationReturn {
|
||||||
|
position: GeoPosition | null
|
||||||
|
mode: TrackingMode
|
||||||
|
error: string | null
|
||||||
|
/** Toggle through off → show → follow → off. Also triggers iOS orientation permission on first call. */
|
||||||
|
cycleMode: () => Promise<void>
|
||||||
|
/** Force-set mode. Accepts a function for derived updates like `prev => prev === 'follow' ? 'show' : prev`. */
|
||||||
|
setMode: (m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a tiny EMA on heading so the compass cone doesn't jitter on every
|
||||||
|
// device orientation event. Mobile sensors fire at 60Hz and raw readings
|
||||||
|
// swing ±5° even when the phone is still — smoothing to ~0.25 weight
|
||||||
|
// gives a stable-but-responsive needle.
|
||||||
|
function smoothAngle(prev: number | null, next: number, alpha = 0.25): number {
|
||||||
|
if (prev === null) return next
|
||||||
|
// Take the shortest angular distance so we don't lerp the long way around
|
||||||
|
let delta = next - prev
|
||||||
|
if (delta > 180) delta -= 360
|
||||||
|
if (delta < -180) delta += 360
|
||||||
|
return (prev + delta * alpha + 360) % 360
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeolocation(): UseGeolocationReturn {
|
||||||
|
const [position, setPosition] = useState<GeoPosition | null>(null)
|
||||||
|
const [mode, setModeState] = useState<TrackingMode>('off')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const watchIdRef = useRef<number | null>(null)
|
||||||
|
const orientationHandlerRef = useRef<((e: DeviceOrientationEvent) => void) | null>(null)
|
||||||
|
const headingRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const stopWatch = useCallback(() => {
|
||||||
|
if (watchIdRef.current !== null) {
|
||||||
|
try { navigator.geolocation.clearWatch(watchIdRef.current) } catch { /* noop */ }
|
||||||
|
watchIdRef.current = null
|
||||||
|
}
|
||||||
|
if (orientationHandlerRef.current) {
|
||||||
|
window.removeEventListener('deviceorientationabsolute', orientationHandlerRef.current as EventListener)
|
||||||
|
window.removeEventListener('deviceorientation', orientationHandlerRef.current as EventListener)
|
||||||
|
orientationHandlerRef.current = null
|
||||||
|
}
|
||||||
|
headingRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startWatch = useCallback(async () => {
|
||||||
|
if (!('geolocation' in navigator)) {
|
||||||
|
setError('Geolocation is not supported in this browser')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// iOS: ask for orientation permission up front; on Android and desktop
|
||||||
|
// no prompt is needed and the method is undefined.
|
||||||
|
const DOE = (window.DeviceOrientationEvent || {}) as DeviceOrientationEventIOS
|
||||||
|
if (typeof DOE.requestPermission === 'function') {
|
||||||
|
try {
|
||||||
|
const res = await DOE.requestPermission()
|
||||||
|
if (res !== 'granted') {
|
||||||
|
// Permission denied — we still enable location, just no heading cone.
|
||||||
|
}
|
||||||
|
} catch { /* older webkit throws — ignore and proceed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device orientation → compass heading. `alpha` is rotation around the
|
||||||
|
// Z-axis (0 = facing magnetic north on most devices). The webkit-only
|
||||||
|
// `webkitCompassHeading` is already geographic north + clockwise, so
|
||||||
|
// prefer it when available.
|
||||||
|
const onOrientation = (e: DeviceOrientationEvent) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ev = e as any
|
||||||
|
let heading: number | null = null
|
||||||
|
if (typeof ev.webkitCompassHeading === 'number') {
|
||||||
|
heading = ev.webkitCompassHeading
|
||||||
|
} else if (e.absolute && typeof e.alpha === 'number') {
|
||||||
|
// alpha is CCW from North; convert to CW heading
|
||||||
|
heading = (360 - e.alpha) % 360
|
||||||
|
} else if (typeof e.alpha === 'number') {
|
||||||
|
// Non-absolute orientation: better than nothing but drifts over time
|
||||||
|
heading = (360 - e.alpha) % 360
|
||||||
|
}
|
||||||
|
if (heading === null || Number.isNaN(heading)) return
|
||||||
|
headingRef.current = smoothAngle(headingRef.current, heading)
|
||||||
|
// Merge into position without triggering a refetch
|
||||||
|
setPosition(p => p ? { ...p, heading: headingRef.current } : p)
|
||||||
|
}
|
||||||
|
orientationHandlerRef.current = onOrientation
|
||||||
|
// Prefer "absolute" which is tied to magnetic north; fall back to plain.
|
||||||
|
window.addEventListener('deviceorientationabsolute', onOrientation as EventListener)
|
||||||
|
window.addEventListener('deviceorientation', onOrientation as EventListener)
|
||||||
|
|
||||||
|
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => {
|
||||||
|
setPosition({
|
||||||
|
lat: pos.coords.latitude,
|
||||||
|
lng: pos.coords.longitude,
|
||||||
|
accuracy: pos.coords.accuracy,
|
||||||
|
// GPS heading is reliable when moving; keep compass reading
|
||||||
|
// otherwise so the arrow still points correctly when stationary.
|
||||||
|
heading: pos.coords.heading ?? headingRef.current,
|
||||||
|
speed: pos.coords.speed ?? null,
|
||||||
|
timestamp: pos.timestamp,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setError(err.message || 'Location unavailable')
|
||||||
|
// Stay subscribed so a later fix can still recover (e.g. GPS
|
||||||
|
// lock takes a while indoors). Only fully stop on permission denial.
|
||||||
|
if (err.code === err.PERMISSION_DENIED) {
|
||||||
|
stopWatch()
|
||||||
|
setModeState('off')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
maximumAge: 2000,
|
||||||
|
timeout: 15000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}, [stopWatch])
|
||||||
|
|
||||||
|
const setMode = useCallback((m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {
|
||||||
|
setModeState(prev => {
|
||||||
|
const next = typeof m === 'function' ? m(prev) : m
|
||||||
|
if (next === 'off') {
|
||||||
|
stopWatch()
|
||||||
|
setPosition(null)
|
||||||
|
} else if (watchIdRef.current === null) {
|
||||||
|
// started externally but no watch yet — start it
|
||||||
|
startWatch()
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [startWatch, stopWatch])
|
||||||
|
|
||||||
|
const cycleMode = useCallback(async () => {
|
||||||
|
if (mode === 'off') {
|
||||||
|
const ok = await startWatch()
|
||||||
|
if (ok) setModeState('show')
|
||||||
|
} else if (mode === 'show') {
|
||||||
|
setModeState('follow')
|
||||||
|
} else {
|
||||||
|
setModeState('off')
|
||||||
|
stopWatch()
|
||||||
|
setPosition(null)
|
||||||
|
}
|
||||||
|
}, [mode, startWatch, stopWatch])
|
||||||
|
|
||||||
|
useEffect(() => stopWatch, [stopWatch])
|
||||||
|
|
||||||
|
return { position, mode, error, cycleMode, setMode }
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ 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': 'كلمة المرور',
|
||||||
@@ -161,6 +163,24 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
|
'settings.mapDefaultHint': 'اتركه فارغًا لاستخدام OpenStreetMap افتراضيًا',
|
||||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
'settings.mapHint': 'قالب URL لبلاطات الخريطة',
|
'settings.mapHint': 'قالب URL لبلاطات الخريطة',
|
||||||
|
'settings.mapProvider': 'مزود الخريطة',
|
||||||
|
'settings.mapProviderHint': 'يؤثر على خرائط Trip Planner و Journey. يستخدم Atlas دائمًا Leaflet.',
|
||||||
|
'settings.mapLeafletSubtitle': '2D كلاسيكي، أي بلاطات نقطية',
|
||||||
|
'settings.mapMapboxSubtitle': 'بلاطات متجهية ومبانٍ ثلاثية الأبعاد وتضاريس',
|
||||||
|
'settings.mapExperimental': 'تجريبي',
|
||||||
|
'settings.mapMapboxToken': 'رمز وصول Mapbox',
|
||||||
|
'settings.mapMapboxTokenHint': 'الرمز العام (pk.*) من',
|
||||||
|
'settings.mapMapboxTokenLink': 'mapbox.com ← رموز الوصول',
|
||||||
|
'settings.mapStyle': 'نمط الخريطة',
|
||||||
|
'settings.mapStylePlaceholder': 'اختر نمط Mapbox',
|
||||||
|
'settings.mapStyleHint': 'إعداد مسبق أو عنوان URL mapbox://styles/USER/ID خاص بك',
|
||||||
|
'settings.map3dBuildings': 'مبانٍ ثلاثية الأبعاد وتضاريس',
|
||||||
|
'settings.map3dHint': 'إمالة + مبانٍ ثلاثية الأبعاد حقيقية — يعمل مع كل نمط بما في ذلك الأقمار الصناعية.',
|
||||||
|
'settings.mapHighQuality': 'وضع الجودة العالية',
|
||||||
|
'settings.mapHighQualityHint': 'تحسين الحواف + إسقاط كروي لحواف أكثر حدة وعرض واقعي للعالم.',
|
||||||
|
'settings.mapHighQualityWarning': 'قد يؤثر على الأداء في الأجهزة الأقل قدرة.',
|
||||||
|
'settings.mapTipLabel': 'نصيحة:',
|
||||||
|
'settings.mapTip': 'انقر بزر الماوس الأيمن واسحب لتدوير/إمالة الخريطة. النقر الأوسط لإضافة مكان (النقر الأيمن مخصص للتدوير).',
|
||||||
'settings.latitude': 'خط العرض',
|
'settings.latitude': 'خط العرض',
|
||||||
'settings.longitude': 'خط الطول',
|
'settings.longitude': 'خط الطول',
|
||||||
'settings.saveMap': 'حفظ الخريطة',
|
'settings.saveMap': 'حفظ الخريطة',
|
||||||
@@ -186,6 +206,7 @@ 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)',
|
||||||
@@ -445,6 +466,28 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
|
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
|
||||||
'login.usernameRequired': 'اسم المستخدم مطلوب',
|
'login.usernameRequired': 'اسم المستخدم مطلوب',
|
||||||
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
|
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
|
||||||
|
'login.forgotPassword': 'نسيت كلمة المرور؟',
|
||||||
|
'login.forgotPasswordTitle': 'إعادة تعيين كلمة المرور',
|
||||||
|
'login.forgotPasswordBody': 'أدخل عنوان البريد الإلكتروني المسجَّل. إذا كان الحساب موجودًا، سنرسل رابط إعادة التعيين.',
|
||||||
|
'login.forgotPasswordSubmit': 'إرسال الرابط',
|
||||||
|
'login.forgotPasswordSentTitle': 'تحقق من بريدك',
|
||||||
|
'login.forgotPasswordSentBody': 'إذا كان هناك حساب مرتبط بهذا البريد، فإن الرابط في الطريق. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||||
|
'login.forgotPasswordSmtpHintOff': 'ملاحظة: لم يقم المسؤول بتكوين SMTP، لذا سيتم كتابة رابط إعادة التعيين في وحدة تحكم الخادم بدلاً من إرساله عبر البريد الإلكتروني.',
|
||||||
|
'login.backToLogin': 'العودة إلى تسجيل الدخول',
|
||||||
|
'login.newPassword': 'كلمة المرور الجديدة',
|
||||||
|
'login.confirmPassword': 'تأكيد كلمة المرور الجديدة',
|
||||||
|
'login.passwordsDontMatch': 'كلمتا المرور غير متطابقتين',
|
||||||
|
'login.mfaCode': 'رمز 2FA',
|
||||||
|
'login.resetPasswordTitle': 'ضبط كلمة مرور جديدة',
|
||||||
|
'login.resetPasswordBody': 'اختر كلمة مرور قوية لم تستخدمها هنا من قبل. 8 أحرف على الأقل.',
|
||||||
|
'login.resetPasswordMfaBody': 'أدخل رمز 2FA أو رمز النسخ الاحتياطي لإتمام إعادة التعيين.',
|
||||||
|
'login.resetPasswordSubmit': 'إعادة تعيين كلمة المرور',
|
||||||
|
'login.resetPasswordVerify': 'تحقق وأعد التعيين',
|
||||||
|
'login.resetPasswordSuccessTitle': 'تم تحديث كلمة المرور',
|
||||||
|
'login.resetPasswordSuccessBody': 'يمكنك الآن تسجيل الدخول بكلمة المرور الجديدة.',
|
||||||
|
'login.resetPasswordInvalidLink': 'رابط إعادة تعيين غير صالح',
|
||||||
|
'login.resetPasswordInvalidLinkBody': 'هذا الرابط مفقود أو تالف. اطلب رابطًا جديدًا للمتابعة.',
|
||||||
|
'login.resetPasswordFailed': 'فشلت إعادة التعيين. ربما انتهت صلاحية الرابط.',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
'register.passwordMismatch': 'كلمتا المرور غير متطابقتين',
|
||||||
@@ -1181,6 +1224,8 @@ 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': 'ملف واحد',
|
||||||
@@ -1204,6 +1249,7 @@ 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': 'سلة المهملات',
|
||||||
@@ -1216,6 +1262,7 @@ 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': 'تم النقل إلى سلة المهملات',
|
||||||
@@ -1577,8 +1624,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'كلمة المرور',
|
'memories.providerPassword': 'كلمة المرور',
|
||||||
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
|
'memories.providerOTP': 'رمز MFA (إذا كان مفعلاً)',
|
||||||
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
|
'memories.skipSSLVerification': 'تخطي التحقق من شهادة SSL',
|
||||||
|
'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': 'غير متصل',
|
||||||
@@ -1655,6 +1704,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.invite.inviting': 'جارٍ الدعوة...',
|
'journey.invite.inviting': 'جارٍ الدعوة...',
|
||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
|
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||||
'journey.editor.uploadPhotos': 'رفع صور',
|
'journey.editor.uploadPhotos': 'رفع صور',
|
||||||
'journey.editor.uploading': '...جارٍ الرفع',
|
'journey.editor.uploading': '...جارٍ الرفع',
|
||||||
'journey.editor.fromGallery': 'من المعرض',
|
'journey.editor.fromGallery': 'من المعرض',
|
||||||
@@ -1953,6 +2003,8 @@ 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': 'تمت مشاركة الصور',
|
||||||
@@ -1990,6 +2042,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'oauth.scope.group.vacay': 'الإجازة',
|
'oauth.scope.group.vacay': 'الإجازة',
|
||||||
'oauth.scope.group.geo': 'Geo',
|
'oauth.scope.group.geo': 'Geo',
|
||||||
'oauth.scope.group.weather': 'الطقس',
|
'oauth.scope.group.weather': 'الطقس',
|
||||||
|
'oauth.scope.group.journey': 'مذكرة السفر',
|
||||||
|
|
||||||
// OAuth scope labels & descriptions
|
// OAuth scope labels & descriptions
|
||||||
'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر',
|
'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر',
|
||||||
@@ -2040,6 +2093,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
|
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
|
||||||
'oauth.scope.weather:read.label': 'توقعات الطقس',
|
'oauth.scope.weather:read.label': 'توقعات الطقس',
|
||||||
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
|
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
|
||||||
|
'oauth.scope.journey:read.label': 'عرض مذكرات السفر',
|
||||||
|
'oauth.scope.journey:read.description': 'قراءة مذكرات السفر والمدخلات وقائمة المساهمين',
|
||||||
|
'oauth.scope.journey:write.label': 'إدارة مذكرات السفر',
|
||||||
|
'oauth.scope.journey:write.description': 'إنشاء مذكرات السفر وتحديثها وحذفها وإدخالاتها',
|
||||||
|
'oauth.scope.journey:share.label': 'إدارة روابط مذكرات السفر',
|
||||||
|
'oauth.scope.journey:share.description': 'إنشاء روابط مشاركة عامة لمذكرات السفر وتحديثها وإلغاؤها',
|
||||||
|
|
||||||
// System notices
|
// System notices
|
||||||
'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
|
'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
|
||||||
@@ -2084,9 +2143,12 @@ 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) يبقي الأضواء مشتعلة.',
|
||||||
'transport.addTransport': 'Add transport',
|
// System notices — 3.0.14
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'system_notice.v3014_whitespace_collision.title': 'إجراء مطلوب: تعارض في حسابات المستخدمين',
|
||||||
'transport.modalTitle.edit': 'Edit transport',
|
'system_notice.v3014_whitespace_collision.body': 'اكتشف ترقية 3.0.14 تعارضًا في أسماء مستخدمين أو بريد إلكتروني ناتجًا عن مسافات بيضاء في بداية أو نهاية القيم المخزنة. تمت إعادة تسمية الحسابات المتأثرة تلقائيًا. تحقق من سجلات الخادم بحثًا عن أسطر تبدأ بـ **[migration] WHITESPACE COLLISION** لتحديد الحسابات التي تحتاج إلى مراجعة.',
|
||||||
|
'transport.addTransport': 'إضافة وسيلة نقل',
|
||||||
|
'transport.modalTitle.create': 'إضافة وسيلة نقل',
|
||||||
|
'transport.modalTitle.edit': 'تعديل وسيلة النقل',
|
||||||
'transport.title': 'المواصلات',
|
'transport.title': 'المواصلات',
|
||||||
'transport.addManual': 'نقل يدوي',
|
'transport.addManual': 'نقل يدوي',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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',
|
||||||
@@ -156,6 +158,24 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
|
'settings.mapDefaultHint': 'Deixe vazio para OpenStreetMap (padrão)',
|
||||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
'settings.mapHint': 'URL do modelo de blocos do mapa',
|
'settings.mapHint': 'URL do modelo de blocos do mapa',
|
||||||
|
'settings.mapProvider': 'Provedor de mapa',
|
||||||
|
'settings.mapProviderHint': 'Afeta os mapas do Planejador de Viagem e Diário. Atlas sempre usa Leaflet.',
|
||||||
|
'settings.mapLeafletSubtitle': 'Clássico 2D, quaisquer blocos raster',
|
||||||
|
'settings.mapMapboxSubtitle': 'Blocos vetoriais, prédios 3D & terreno',
|
||||||
|
'settings.mapExperimental': 'Experimental',
|
||||||
|
'settings.mapMapboxToken': 'Token de acesso Mapbox',
|
||||||
|
'settings.mapMapboxTokenHint': 'Token público (pk.*) de',
|
||||||
|
'settings.mapMapboxTokenLink': 'mapbox.com → Tokens de acesso',
|
||||||
|
'settings.mapStyle': 'Estilo do mapa',
|
||||||
|
'settings.mapStylePlaceholder': 'Selecionar um estilo Mapbox',
|
||||||
|
'settings.mapStyleHint': 'Preset ou sua própria URL mapbox://styles/USER/ID',
|
||||||
|
'settings.map3dBuildings': 'Prédios 3D & terreno',
|
||||||
|
'settings.map3dHint': 'Inclinação + extrusões 3D reais de prédios — funciona em todo estilo, incluindo satélite.',
|
||||||
|
'settings.mapHighQuality': 'Modo alta qualidade',
|
||||||
|
'settings.mapHighQualityHint': 'Antialiasing + projeção global para bordas mais nítidas e uma visão realista do mundo.',
|
||||||
|
'settings.mapHighQualityWarning': 'Pode afetar o desempenho em dispositivos menos potentes.',
|
||||||
|
'settings.mapTipLabel': 'Dica:',
|
||||||
|
'settings.mapTip': 'Clique direito e arraste para girar/inclinar o mapa. Clique do meio para adicionar um local (o clique direito é reservado para rotação).',
|
||||||
'settings.latitude': 'Latitude',
|
'settings.latitude': 'Latitude',
|
||||||
'settings.longitude': 'Longitude',
|
'settings.longitude': 'Longitude',
|
||||||
'settings.saveMap': 'Salvar mapa',
|
'settings.saveMap': 'Salvar mapa',
|
||||||
@@ -181,6 +201,7 @@ 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)',
|
||||||
@@ -440,6 +461,28 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.oidcFailed': 'Falha no login OIDC',
|
'login.oidcFailed': 'Falha no login OIDC',
|
||||||
'login.usernameRequired': 'Nome de usuário é obrigatório',
|
'login.usernameRequired': 'Nome de usuário é obrigatório',
|
||||||
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
|
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
|
||||||
|
'login.forgotPassword': 'Esqueceu a senha?',
|
||||||
|
'login.forgotPasswordTitle': 'Redefinir sua senha',
|
||||||
|
'login.forgotPasswordBody': 'Digite o e-mail cadastrado. Se houver uma conta, enviaremos um link de redefinição.',
|
||||||
|
'login.forgotPasswordSubmit': 'Enviar link',
|
||||||
|
'login.forgotPasswordSentTitle': 'Verifique seu e-mail',
|
||||||
|
'login.forgotPasswordSentBody': 'Se houver uma conta para esse e-mail, o link está a caminho. Ele expira em 60 minutos.',
|
||||||
|
'login.forgotPasswordSmtpHintOff': 'Observação: seu administrador não configurou SMTP, então o link de redefinição será gravado no console do servidor em vez de ser enviado por e-mail.',
|
||||||
|
'login.backToLogin': 'Voltar ao login',
|
||||||
|
'login.newPassword': 'Nova senha',
|
||||||
|
'login.confirmPassword': 'Confirmar nova senha',
|
||||||
|
'login.passwordsDontMatch': 'As senhas não coincidem',
|
||||||
|
'login.mfaCode': 'Código 2FA',
|
||||||
|
'login.resetPasswordTitle': 'Definir uma nova senha',
|
||||||
|
'login.resetPasswordBody': 'Escolha uma senha forte que você ainda não tenha usado aqui. Mínimo de 8 caracteres.',
|
||||||
|
'login.resetPasswordMfaBody': 'Digite seu código 2FA ou um código de backup para concluir a redefinição.',
|
||||||
|
'login.resetPasswordSubmit': 'Redefinir senha',
|
||||||
|
'login.resetPasswordVerify': 'Verificar e redefinir',
|
||||||
|
'login.resetPasswordSuccessTitle': 'Senha atualizada',
|
||||||
|
'login.resetPasswordSuccessBody': 'Agora você pode entrar com sua nova senha.',
|
||||||
|
'login.resetPasswordInvalidLink': 'Link de redefinição inválido',
|
||||||
|
'login.resetPasswordInvalidLinkBody': 'Este link está ausente ou corrompido. Solicite um novo para continuar.',
|
||||||
|
'login.resetPasswordFailed': 'Falha na redefinição. O link pode ter expirado.',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'As senhas não coincidem',
|
'register.passwordMismatch': 'As senhas não coincidem',
|
||||||
@@ -1150,6 +1193,8 @@ 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',
|
||||||
@@ -1173,6 +1218,7 @@ 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',
|
||||||
@@ -1185,6 +1231,7 @@ 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',
|
||||||
@@ -1616,8 +1663,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Senha',
|
'memories.providerPassword': 'Senha',
|
||||||
'memories.providerOTP': 'Código MFA (se habilitado)',
|
'memories.providerOTP': 'Código MFA (se habilitado)',
|
||||||
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
|
'memories.skipSSLVerification': 'Pular verificação de certificado SSL',
|
||||||
|
'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',
|
||||||
@@ -1894,6 +1943,8 @@ 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',
|
||||||
@@ -2025,6 +2076,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
|
'journey.verdict.couldBeBetter': 'Poderia ser melhor',
|
||||||
'journey.synced.places': 'lugares',
|
'journey.synced.places': 'lugares',
|
||||||
'journey.synced.synced': 'sincronizado',
|
'journey.synced.synced': 'sincronizado',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
||||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||||
'journey.editor.uploading': 'Enviando...',
|
'journey.editor.uploading': 'Enviando...',
|
||||||
'journey.editor.fromGallery': 'Da galeria',
|
'journey.editor.fromGallery': 'Da galeria',
|
||||||
@@ -2193,6 +2245,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'oauth.scope.group.vacay': 'Férias',
|
'oauth.scope.group.vacay': 'Férias',
|
||||||
'oauth.scope.group.geo': 'Geo',
|
'oauth.scope.group.geo': 'Geo',
|
||||||
'oauth.scope.group.weather': 'Clima',
|
'oauth.scope.group.weather': 'Clima',
|
||||||
|
'oauth.scope.group.journey': 'Jornada',
|
||||||
|
|
||||||
// OAuth scope labels & descriptions
|
// OAuth scope labels & descriptions
|
||||||
'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
|
'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
|
||||||
@@ -2243,6 +2296,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
|
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
|
||||||
'oauth.scope.weather:read.label': 'Previsão do tempo',
|
'oauth.scope.weather:read.label': 'Previsão do tempo',
|
||||||
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
|
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
|
||||||
|
'oauth.scope.journey:read.label': 'Ver jornadas',
|
||||||
|
'oauth.scope.journey:read.description': 'Ler jornadas, entradas e lista de colaboradores',
|
||||||
|
'oauth.scope.journey:write.label': 'Gerenciar jornadas',
|
||||||
|
'oauth.scope.journey:write.description': 'Criar, atualizar e excluir jornadas e suas entradas',
|
||||||
|
'oauth.scope.journey:share.label': 'Gerenciar links de jornadas',
|
||||||
|
'oauth.scope.journey:share.description': 'Criar, atualizar e revogar links de compartilhamento públicos para jornadas',
|
||||||
|
|
||||||
// System notices
|
// System notices
|
||||||
'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
|
'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
|
||||||
@@ -2287,9 +2346,12 @@ 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.',
|
||||||
'transport.addTransport': 'Add transport',
|
// System notices — 3.0.14
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'system_notice.v3014_whitespace_collision.title': 'Ação necessária: conflito de conta de usuário',
|
||||||
'transport.modalTitle.edit': 'Edit 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.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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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',
|
||||||
@@ -157,6 +159,24 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)',
|
'settings.mapDefaultHint': 'Ponechte prázdné pro OpenStreetMap (výchozí)',
|
||||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
'settings.mapHint': 'URL šablony pro mapové dlaždice',
|
'settings.mapHint': 'URL šablony pro mapové dlaždice',
|
||||||
|
'settings.mapProvider': 'Poskytovatel mapy',
|
||||||
|
'settings.mapProviderHint': 'Ovlivňuje mapy v Trip Planneru a Journey. Atlas vždy používá Leaflet.',
|
||||||
|
'settings.mapLeafletSubtitle': 'Klasické 2D, libovolné rastrové dlaždice',
|
||||||
|
'settings.mapMapboxSubtitle': 'Vektorové dlaždice, 3D budovy a terén',
|
||||||
|
'settings.mapExperimental': 'Experimentální',
|
||||||
|
'settings.mapMapboxToken': 'Mapbox přístupový token',
|
||||||
|
'settings.mapMapboxTokenHint': 'Veřejný token (pk.*) z',
|
||||||
|
'settings.mapMapboxTokenLink': 'mapbox.com → Přístupové tokeny',
|
||||||
|
'settings.mapStyle': 'Styl mapy',
|
||||||
|
'settings.mapStylePlaceholder': 'Vyberte styl Mapbox',
|
||||||
|
'settings.mapStyleHint': 'Preset nebo vaše vlastní URL mapbox://styles/USER/ID',
|
||||||
|
'settings.map3dBuildings': '3D budovy a terén',
|
||||||
|
'settings.map3dHint': 'Náklon + skutečné 3D vyvýšení budov — funguje s každým stylem, včetně satelitu.',
|
||||||
|
'settings.mapHighQuality': 'Režim vysoké kvality',
|
||||||
|
'settings.mapHighQualityHint': 'Antialiasing + zobrazení glóbu pro ostřejší hrany a realistický pohled na svět.',
|
||||||
|
'settings.mapHighQualityWarning': 'Může ovlivnit výkon na slabších zařízeních.',
|
||||||
|
'settings.mapTipLabel': 'Tip:',
|
||||||
|
'settings.mapTip': 'Pravé tlačítko myši a táhněte pro rotaci/náklon mapy. Prostřední tlačítko pro přidání místa (pravé tlačítko je vyhrazeno pro rotaci).',
|
||||||
'settings.latitude': 'Zeměpisná šířka',
|
'settings.latitude': 'Zeměpisná šířka',
|
||||||
'settings.longitude': 'Zeměpisná délka',
|
'settings.longitude': 'Zeměpisná délka',
|
||||||
'settings.saveMap': 'Uložit nastavení mapy',
|
'settings.saveMap': 'Uložit nastavení mapy',
|
||||||
@@ -182,6 +202,7 @@ 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)',
|
||||||
@@ -440,6 +461,28 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
|
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
|
||||||
'login.usernameRequired': 'Uživatelské jméno je povinné',
|
'login.usernameRequired': 'Uživatelské jméno je povinné',
|
||||||
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
|
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
|
||||||
|
'login.forgotPassword': 'Zapomenuté heslo?',
|
||||||
|
'login.forgotPasswordTitle': 'Obnovení hesla',
|
||||||
|
'login.forgotPasswordBody': 'Zadej e-mail použitý při registraci. Pokud účet existuje, pošleme odkaz pro obnovení.',
|
||||||
|
'login.forgotPasswordSubmit': 'Odeslat odkaz',
|
||||||
|
'login.forgotPasswordSentTitle': 'Zkontroluj e-mail',
|
||||||
|
'login.forgotPasswordSentBody': 'Pokud k tomuto e-mailu existuje účet, odkaz je na cestě. Platnost vyprší za 60 minut.',
|
||||||
|
'login.forgotPasswordSmtpHintOff': 'Upozornění: správce nemá nakonfigurovaný SMTP, takže se odkaz pro obnovení zapíše do konzole serveru místo odeslání e-mailem.',
|
||||||
|
'login.backToLogin': 'Zpět na přihlášení',
|
||||||
|
'login.newPassword': 'Nové heslo',
|
||||||
|
'login.confirmPassword': 'Potvrď nové heslo',
|
||||||
|
'login.passwordsDontMatch': 'Hesla se neshodují',
|
||||||
|
'login.mfaCode': 'Kód 2FA',
|
||||||
|
'login.resetPasswordTitle': 'Nastavit nové heslo',
|
||||||
|
'login.resetPasswordBody': 'Vyber silné heslo, které jsi tu ještě nepoužil. Minimálně 8 znaků.',
|
||||||
|
'login.resetPasswordMfaBody': 'Zadej 2FA kód nebo záložní kód pro dokončení obnovení.',
|
||||||
|
'login.resetPasswordSubmit': 'Obnovit heslo',
|
||||||
|
'login.resetPasswordVerify': 'Ověřit a obnovit',
|
||||||
|
'login.resetPasswordSuccessTitle': 'Heslo aktualizováno',
|
||||||
|
'login.resetPasswordSuccessBody': 'Nyní se můžeš přihlásit novým heslem.',
|
||||||
|
'login.resetPasswordInvalidLink': 'Neplatný odkaz',
|
||||||
|
'login.resetPasswordInvalidLinkBody': 'Odkaz chybí nebo je poškozený. Pro pokračování si vyžádej nový.',
|
||||||
|
'login.resetPasswordFailed': 'Obnovení se nezdařilo. Odkaz mohl vypršet.',
|
||||||
|
|
||||||
// Registrace (Register)
|
// Registrace (Register)
|
||||||
'register.passwordMismatch': 'Hesla se neshodují',
|
'register.passwordMismatch': 'Hesla se neshodují',
|
||||||
@@ -1179,6 +1222,8 @@ 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',
|
||||||
@@ -1202,6 +1247,7 @@ 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š',
|
||||||
@@ -1214,6 +1260,7 @@ 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',
|
||||||
@@ -1575,8 +1622,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Heslo',
|
'memories.providerPassword': 'Heslo',
|
||||||
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
|
'memories.providerOTP': 'MFA kód (pokud je povoleno)',
|
||||||
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
|
'memories.skipSSLVerification': 'Přeskočit ověření SSL certifikátu',
|
||||||
|
'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',
|
||||||
@@ -1899,6 +1948,8 @@ 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',
|
||||||
@@ -2030,6 +2081,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
|
'journey.verdict.couldBeBetter': 'Mohlo by být lepší',
|
||||||
'journey.synced.places': 'místa',
|
'journey.synced.places': 'místa',
|
||||||
'journey.synced.synced': 'synchronizováno',
|
'journey.synced.synced': 'synchronizováno',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
||||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||||
'journey.editor.uploading': 'Nahrávání...',
|
'journey.editor.uploading': 'Nahrávání...',
|
||||||
'journey.editor.fromGallery': 'Z galerie',
|
'journey.editor.fromGallery': 'Z galerie',
|
||||||
@@ -2197,6 +2249,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'oauth.scope.group.vacay': 'Dovolená',
|
'oauth.scope.group.vacay': 'Dovolená',
|
||||||
'oauth.scope.group.geo': 'Geo',
|
'oauth.scope.group.geo': 'Geo',
|
||||||
'oauth.scope.group.weather': 'Počasí',
|
'oauth.scope.group.weather': 'Počasí',
|
||||||
|
'oauth.scope.group.journey': 'Cestovní deník',
|
||||||
|
|
||||||
// OAuth scope labels & descriptions
|
// OAuth scope labels & descriptions
|
||||||
'oauth.scope.trips:read.label': 'Zobrazit výlety a itineráře',
|
'oauth.scope.trips:read.label': 'Zobrazit výlety a itineráře',
|
||||||
@@ -2247,6 +2300,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
|
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
|
||||||
'oauth.scope.weather:read.label': 'Předpovědi počasí',
|
'oauth.scope.weather:read.label': 'Předpovědi počasí',
|
||||||
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
|
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
|
||||||
|
'oauth.scope.journey:read.label': 'Zobrazit cestovní deníky',
|
||||||
|
'oauth.scope.journey:read.description': 'Číst cestovní deníky, záznamy a seznam přispěvatelů',
|
||||||
|
'oauth.scope.journey:write.label': 'Spravovat cestovní deníky',
|
||||||
|
'oauth.scope.journey:write.description': 'Vytvářet, aktualizovat a mazat cestovní deníky a jejich záznamy',
|
||||||
|
'oauth.scope.journey:share.label': 'Spravovat odkazy na cestovní deníky',
|
||||||
|
'oauth.scope.journey:share.description': 'Vytvářet, aktualizovat a rušit veřejné sdílené odkazy na cestovní deníky',
|
||||||
|
|
||||||
// System notices
|
// System notices
|
||||||
'system_notice.welcome_v1.title': 'Vítejte v TREK',
|
'system_notice.welcome_v1.title': 'Vítejte v TREK',
|
||||||
@@ -2291,9 +2350,12 @@ 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á.',
|
||||||
'transport.addTransport': 'Add transport',
|
// System notices — 3.0.14
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'system_notice.v3014_whitespace_collision.title': 'Vyžadována akce: konflikt uživatelského účtu',
|
||||||
'transport.modalTitle.edit': 'Edit 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.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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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',
|
||||||
@@ -148,7 +150,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
|
||||||
'settings.tabs.display': 'Anzeige',
|
'settings.tabs.display': 'Anzeige',
|
||||||
'settings.tabs.map': 'Karte',
|
'settings.tabs.map': 'Karte',
|
||||||
'settings.tabs.notifications': 'Benachrichtigungen',
|
'settings.tabs.notifications': 'Mitteilungen',
|
||||||
'settings.tabs.integrations': 'Integrationen',
|
'settings.tabs.integrations': 'Integrationen',
|
||||||
'settings.tabs.account': 'Konto',
|
'settings.tabs.account': 'Konto',
|
||||||
'settings.tabs.offline': 'Offline',
|
'settings.tabs.offline': 'Offline',
|
||||||
@@ -159,6 +161,24 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
|
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
|
||||||
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
'settings.mapHint': 'URL-Template für die Kartenkacheln',
|
'settings.mapHint': 'URL-Template für die Kartenkacheln',
|
||||||
|
'settings.mapProvider': 'Kartenanbieter',
|
||||||
|
'settings.mapProviderHint': 'Gilt für Trip Planner und Journey. Atlas nutzt immer Leaflet.',
|
||||||
|
'settings.mapLeafletSubtitle': 'Klassisch 2D, beliebige Raster-Kacheln',
|
||||||
|
'settings.mapMapboxSubtitle': 'Vektor-Kacheln, 3D-Gebäude & Terrain',
|
||||||
|
'settings.mapExperimental': 'Experimentell',
|
||||||
|
'settings.mapMapboxToken': 'Mapbox Access Token',
|
||||||
|
'settings.mapMapboxTokenHint': 'Öffentliches Token (pk.*) von',
|
||||||
|
'settings.mapMapboxTokenLink': 'mapbox.com → Access Tokens',
|
||||||
|
'settings.mapStyle': 'Kartenstil',
|
||||||
|
'settings.mapStylePlaceholder': 'Mapbox-Stil wählen',
|
||||||
|
'settings.mapStyleHint': 'Preset oder eigene mapbox://styles/USER/ID URL',
|
||||||
|
'settings.map3dBuildings': '3D-Gebäude & Terrain',
|
||||||
|
'settings.map3dHint': 'Neigung + echte 3D-Gebäude-Extrusionen — funktioniert mit jedem Stil, auch Satellit.',
|
||||||
|
'settings.mapHighQuality': 'Hochqualitäts-Modus',
|
||||||
|
'settings.mapHighQualityHint': 'Antialiasing + Globus-Projektion für schärfere Kanten und eine realistische Weltsicht.',
|
||||||
|
'settings.mapHighQualityWarning': 'Kann die Performance auf schwächeren Geräten beeinträchtigen.',
|
||||||
|
'settings.mapTipLabel': 'Tipp:',
|
||||||
|
'settings.mapTip': 'Rechtsklick und ziehen, um die Karte zu drehen/neigen. Mittelklick, um einen Ort hinzuzufügen (Rechtsklick ist für die Rotation reserviert).',
|
||||||
'settings.latitude': 'Breitengrad',
|
'settings.latitude': 'Breitengrad',
|
||||||
'settings.longitude': 'Längengrad',
|
'settings.longitude': 'Längengrad',
|
||||||
'settings.saveMap': 'Karte speichern',
|
'settings.saveMap': 'Karte speichern',
|
||||||
@@ -182,10 +202,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
||||||
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
||||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||||
'settings.notifications': 'Benachrichtigungen',
|
'settings.notifications': 'Mitteilungen',
|
||||||
'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)',
|
||||||
@@ -445,6 +466,28 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
|
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
|
||||||
'login.usernameRequired': 'Benutzername ist erforderlich',
|
'login.usernameRequired': 'Benutzername ist erforderlich',
|
||||||
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
|
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
|
||||||
|
'login.forgotPassword': 'Passwort vergessen?',
|
||||||
|
'login.forgotPasswordTitle': 'Passwort zurücksetzen',
|
||||||
|
'login.forgotPasswordBody': 'Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Reset-Link.',
|
||||||
|
'login.forgotPasswordSubmit': 'Reset-Link senden',
|
||||||
|
'login.forgotPasswordSentTitle': 'Prüfe deine E-Mails',
|
||||||
|
'login.forgotPasswordSentBody': 'Falls ein Konto mit dieser Adresse existiert, ist ein Reset-Link unterwegs. Er läuft in 60 Minuten ab.',
|
||||||
|
'login.forgotPasswordSmtpHintOff': 'Hinweis: Der Administrator hat SMTP nicht konfiguriert. Der Reset-Link wird statt per E-Mail in die Server-Konsole geschrieben.',
|
||||||
|
'login.backToLogin': 'Zurück zur Anmeldung',
|
||||||
|
'login.newPassword': 'Neues Passwort',
|
||||||
|
'login.confirmPassword': 'Neues Passwort bestätigen',
|
||||||
|
'login.passwordsDontMatch': 'Passwörter stimmen nicht überein',
|
||||||
|
'login.mfaCode': '2FA-Code',
|
||||||
|
'login.resetPasswordTitle': 'Neues Passwort festlegen',
|
||||||
|
'login.resetPasswordBody': 'Wähle ein starkes Passwort, das du hier noch nicht verwendet hast. Mindestens 8 Zeichen.',
|
||||||
|
'login.resetPasswordMfaBody': 'Gib deinen 2FA-Code oder einen Backup-Code ein, um den Reset abzuschließen.',
|
||||||
|
'login.resetPasswordSubmit': 'Passwort zurücksetzen',
|
||||||
|
'login.resetPasswordVerify': 'Prüfen & zurücksetzen',
|
||||||
|
'login.resetPasswordSuccessTitle': 'Passwort aktualisiert',
|
||||||
|
'login.resetPasswordSuccessBody': 'Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
|
||||||
|
'login.resetPasswordInvalidLink': 'Ungültiger Reset-Link',
|
||||||
|
'login.resetPasswordInvalidLinkBody': 'Dieser Link fehlt oder ist beschädigt. Fordere einen neuen an, um fortzufahren.',
|
||||||
|
'login.resetPasswordFailed': 'Zurücksetzen fehlgeschlagen. Der Link ist möglicherweise abgelaufen.',
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||||
@@ -873,7 +916,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Trip Planner
|
// Trip Planner
|
||||||
'trip.tabs.plan': 'Karte',
|
'trip.tabs.plan': 'Karte',
|
||||||
'trip.tabs.transports': 'Transporte',
|
'trip.tabs.transports': 'Transport',
|
||||||
'trip.tabs.reservations': 'Buchungen',
|
'trip.tabs.reservations': 'Buchungen',
|
||||||
'trip.tabs.reservationsShort': 'Buchung',
|
'trip.tabs.reservationsShort': 'Buchung',
|
||||||
'trip.tabs.packing': 'Liste',
|
'trip.tabs.packing': 'Liste',
|
||||||
@@ -908,6 +951,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
|
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
|
||||||
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
|
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
|
||||||
'dayplan.addNote': 'Notiz hinzufügen',
|
'dayplan.addNote': 'Notiz hinzufügen',
|
||||||
|
'dayplan.expandAll': 'Alle Tage ausklappen',
|
||||||
|
'dayplan.collapseAll': 'Alle Tage einklappen',
|
||||||
'dayplan.editNote': 'Notiz bearbeiten',
|
'dayplan.editNote': 'Notiz bearbeiten',
|
||||||
'dayplan.noteAdd': 'Notiz hinzufügen',
|
'dayplan.noteAdd': 'Notiz hinzufügen',
|
||||||
'dayplan.noteEdit': 'Notiz bearbeiten',
|
'dayplan.noteEdit': 'Notiz bearbeiten',
|
||||||
@@ -932,7 +977,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||||
'places.importFile': 'Datei importieren',
|
'places.importFile': 'Dateimport',
|
||||||
'places.sidebarDrop': 'Ablegen zum Importieren',
|
'places.sidebarDrop': 'Ablegen zum Importieren',
|
||||||
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
|
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
|
||||||
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
|
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
|
||||||
@@ -1181,6 +1226,8 @@ 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',
|
||||||
@@ -1204,6 +1251,7 @@ 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',
|
||||||
@@ -1216,6 +1264,7 @@ 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',
|
||||||
@@ -1577,8 +1626,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.providerPassword': 'Passwort',
|
'memories.providerPassword': 'Passwort',
|
||||||
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
|
'memories.providerOTP': 'MFA-Code (falls aktiviert)',
|
||||||
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
|
'memories.skipSSLVerification': 'SSL-Zertifikatsprüfung überspringen',
|
||||||
|
'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',
|
||||||
@@ -1902,6 +1953,8 @@ 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',
|
||||||
@@ -2031,6 +2084,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
|
'journey.verdict.couldBeBetter': 'Verbesserungswürdig',
|
||||||
'journey.synced.places': 'Orte',
|
'journey.synced.places': 'Orte',
|
||||||
'journey.synced.synced': 'synchronisiert',
|
'journey.synced.synced': 'synchronisiert',
|
||||||
|
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||||
'journey.editor.uploading': 'Hochladen...',
|
'journey.editor.uploading': 'Hochladen...',
|
||||||
'journey.editor.fromGallery': 'Aus Galerie',
|
'journey.editor.fromGallery': 'Aus Galerie',
|
||||||
@@ -2081,6 +2135,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.contributors.role': 'Rolle',
|
'journey.contributors.role': 'Rolle',
|
||||||
'journey.contributors.added': 'Mitwirkender hinzugefügt',
|
'journey.contributors.added': 'Mitwirkender hinzugefügt',
|
||||||
'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen',
|
'journey.contributors.addFailed': 'Hinzufügen fehlgeschlagen',
|
||||||
|
'journey.contributors.remove': 'Mitwirkenden entfernen',
|
||||||
|
'journey.contributors.removeConfirm': '{username} aus dieser Journey entfernen?',
|
||||||
|
'journey.contributors.removed': 'Mitwirkender entfernt',
|
||||||
|
'journey.contributors.removeFailed': 'Entfernen fehlgeschlagen',
|
||||||
'journey.share.publicShare': 'Öffentlicher Link',
|
'journey.share.publicShare': 'Öffentlicher Link',
|
||||||
'journey.share.createLink': 'Link erstellen',
|
'journey.share.createLink': 'Link erstellen',
|
||||||
'journey.share.linkCreated': 'Link erstellt',
|
'journey.share.linkCreated': 'Link erstellt',
|
||||||
@@ -2197,6 +2255,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'oauth.scope.group.vacay': 'Urlaub',
|
'oauth.scope.group.vacay': 'Urlaub',
|
||||||
'oauth.scope.group.geo': 'Geo',
|
'oauth.scope.group.geo': 'Geo',
|
||||||
'oauth.scope.group.weather': 'Wetter',
|
'oauth.scope.group.weather': 'Wetter',
|
||||||
|
'oauth.scope.group.journey': 'Journey',
|
||||||
|
|
||||||
// OAuth scope labels & descriptions
|
// OAuth scope labels & descriptions
|
||||||
'oauth.scope.trips:read.label': 'Reisen und Reisepläne anzeigen',
|
'oauth.scope.trips:read.label': 'Reisen und Reisepläne anzeigen',
|
||||||
@@ -2247,6 +2306,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
|
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
|
||||||
'oauth.scope.weather:read.label': 'Wettervorhersagen',
|
'oauth.scope.weather:read.label': 'Wettervorhersagen',
|
||||||
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
|
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
|
||||||
|
'oauth.scope.journey:read.label': 'Journeys ansehen',
|
||||||
|
'oauth.scope.journey:read.description': 'Journeys, Einträge und Mitarbeiterliste lesen',
|
||||||
|
'oauth.scope.journey:write.label': 'Journeys verwalten',
|
||||||
|
'oauth.scope.journey:write.description': 'Journeys und deren Einträge erstellen, bearbeiten und löschen',
|
||||||
|
'oauth.scope.journey:share.label': 'Journey-Links verwalten',
|
||||||
|
'oauth.scope.journey:share.description': 'Öffentliche Freigabelinks für Journeys erstellen, aktualisieren und widerrufen',
|
||||||
|
|
||||||
// System notices
|
// System notices
|
||||||
'system_notice.welcome_v1.title': 'Willkommen bei TREK',
|
'system_notice.welcome_v1.title': 'Willkommen bei TREK',
|
||||||
@@ -2291,9 +2356,12 @@ 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.',
|
||||||
'transport.addTransport': 'Add transport',
|
// System notices — 3.0.14
|
||||||
'transport.modalTitle.create': 'Add transport',
|
'system_notice.v3014_whitespace_collision.title': 'Aktion erforderlich: Benutzerkontokonflikt',
|
||||||
'transport.modalTitle.edit': 'Edit 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.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',
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user