mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-24 07:41:47 +00:00
Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cecaa1d30 | |||
| c375e0d6f7 | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 | |||
| e27be5c965 | |||
| 86ee8044da | |||
| 75772445a7 | |||
| bfe6664ac4 | |||
| 117942f45e | |||
| e7211325df | |||
| 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 |
@@ -2,6 +2,7 @@ node_modules
|
|||||||
client/node_modules
|
client/node_modules
|
||||||
server/node_modules
|
server/node_modules
|
||||||
client/dist
|
client/dist
|
||||||
|
shared/dist
|
||||||
data
|
data
|
||||||
uploads
|
uploads
|
||||||
.git
|
.git
|
||||||
@@ -30,3 +31,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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ on:
|
|||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'wiki/**'
|
- 'wiki/**'
|
||||||
- '.github/workflows/wiki.yml'
|
- '.github/workflows/**'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump:
|
bump:
|
||||||
@@ -99,16 +102,15 @@ jobs:
|
|||||||
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
echo "$STABLE → $NEW_VERSION ($BUMP)"
|
||||||
|
|
||||||
# Update package.json files and Helm chart
|
# Update all workspace + root package.json files and the root lockfile in one shot
|
||||||
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
npm version "$NEW_VERSION" --workspaces --include-workspace-root --no-git-tag-version
|
||||||
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
|
|
||||||
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
|
||||||
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
|
||||||
|
|
||||||
# Commit and tag
|
# Commit and tag
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
|
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||||
git tag "v$NEW_VERSION"
|
git tag "v$NEW_VERSION"
|
||||||
git push origin main --follow-tags
|
git push origin main --follow-tags
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -8,10 +8,33 @@ on:
|
|||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
paths:
|
paths:
|
||||||
- 'server/**'
|
- 'server/**'
|
||||||
- '.github/workflows/test.yml'
|
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
|
- 'shared/**'
|
||||||
|
- '.github/workflows/test.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
shared-contracts:
|
||||||
|
name: Shared Contracts (Zod)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --workspace shared
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: cd shared && npm run typecheck
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cd shared && npm test
|
||||||
|
|
||||||
server-tests:
|
server-tests:
|
||||||
name: Server Tests
|
name: Server Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -21,12 +44,24 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: server/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd server && npm ci
|
run: npm ci --workspace shared && npm ci --workspace server
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
|
- name: Build server (tsc -> dist)
|
||||||
|
run: cd server && npm run build
|
||||||
|
|
||||||
|
- name: Typecheck (informational)
|
||||||
|
# Pre-existing type errors in the NestJS rewrite; surfaces them without
|
||||||
|
# blocking CI. Ratchet to blocking once the legacy code is cleaned up.
|
||||||
|
continue-on-error: true
|
||||||
|
run: cd server && npm run typecheck
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd server && npm run test:coverage
|
run: cd server && npm run test:coverage
|
||||||
@@ -48,12 +83,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd client && npm ci
|
run: npm ci --workspace shared && npm ci --workspace client
|
||||||
|
|
||||||
|
- name: Build shared
|
||||||
|
run: npm run build --workspace=shared
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cd client && npm run test:coverage
|
run: cd client && npm run test:coverage
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ jobs:
|
|||||||
- name: Publish to GitHub wiki
|
- name: Publish to GitHub wiki
|
||||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||||
with:
|
with:
|
||||||
strategy: init
|
strategy: clone
|
||||||
|
|||||||
+6
-1
@@ -3,6 +3,10 @@ node_modules/
|
|||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
server/dist/
|
||||||
|
shared/dist/
|
||||||
|
server/public/*
|
||||||
|
!server/public/.gitkeep
|
||||||
|
|
||||||
# Generated PWA icons (built from SVG via prebuild)
|
# Generated PWA icons (built from SVG via prebuild)
|
||||||
client/public/icons/*.png
|
client/public/icons/*.png
|
||||||
@@ -60,4 +64,5 @@ coverage
|
|||||||
.scannerwork
|
.scannerwork
|
||||||
test-data
|
test-data
|
||||||
|
|
||||||
.run
|
.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
|
||||||
|
|||||||
+52
-19
@@ -1,28 +1,60 @@
|
|||||||
# Stage 1: Build React client
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS client-builder
|
FROM node:24-alpine AS shared-builder
|
||||||
WORKDIR /app/client
|
WORKDIR /app
|
||||||
COPY client/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
COPY shared/package.json ./shared/
|
||||||
COPY client/ ./
|
RUN npm ci --workspace=shared
|
||||||
RUN npm run build
|
COPY shared/ ./shared/
|
||||||
|
RUN npm run build --workspace=shared
|
||||||
|
|
||||||
# Stage 2: Production server
|
# ── Stage 2: client ──────────────────────────────────────────────────────────
|
||||||
FROM node:22-alpine
|
FROM node:24-alpine AS client-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY client/package.json ./client/
|
||||||
|
RUN npm ci --workspace=client
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY client/ ./client/
|
||||||
|
RUN npm run build --workspace=client
|
||||||
|
|
||||||
|
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||||
|
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||||
|
FROM node:24-alpine AS server-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json ./shared/
|
||||||
|
COPY server/package.json ./server/
|
||||||
|
RUN npm ci --workspace=server --ignore-scripts
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY server/ ./server/
|
||||||
|
RUN npm run build --workspace=server
|
||||||
|
|
||||||
|
# ── Stage 4: production runtime ──────────────────────────────────────────────
|
||||||
|
FROM node:24-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Timezone support + native deps (better-sqlite3 needs build tools)
|
# Workspace manifests only — source never enters this stage.
|
||||||
COPY server/package*.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
COPY shared/package.json ./shared/
|
||||||
npm ci --production && \
|
COPY server/package.json ./server/
|
||||||
apk del python3 make g++
|
|
||||||
|
|
||||||
COPY server/ ./
|
# better-sqlite3 native addon requires build tools; purged after install.
|
||||||
COPY --from=client-builder /app/client/dist ./public
|
RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
npm ci --workspace=server --omit=dev && \
|
||||||
|
apk del python3 make g++ && \
|
||||||
|
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
|
COPY server/tsconfig.json ./server/
|
||||||
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
|
|
||||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
RUN 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 && \
|
ln -s /app/uploads /app/server/uploads && \
|
||||||
|
ln -s /app/data /app/server/data && \
|
||||||
chown -R node:node /app
|
chown -R node:node /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -36,4 +68,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node --import tsx src/index.ts"]
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec su-exec node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -6,19 +6,29 @@
|
|||||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
### Your trips. Your plan. Your server.
|
<br />
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||||
|
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||||
|
</picture>
|
||||||
|
|
||||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a href="https://demo-nomad.pakulat.org"><img alt="Live Demo" src="https://img.shields.io/badge/Live_Demo-try_it_now-111827?style=for-the-badge" /></a>
|
<a href="https://demo.liketrek.com"><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&logo=docker&logoColor=white" /></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-community-5865F2?style=for-the-badge&logo=discord&logoColor=white" /></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-board-0EA5E9?style=for-the-badge&logo=trello&logoColor=white" /></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 />
|
<br />
|
||||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||||
@@ -117,19 +127,23 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
#### 🧩 Addons (admin-toggleable)
|
#### 🧩 Addons (admin-toggleable)
|
||||||
|
|
||||||
|
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||||
|
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||||
|
- **Documents** — file attachments on trips, places, and reservations
|
||||||
|
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||||
- **Journey** — magazine-style travel journal with entries, photos, maps, moods
|
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||||
- **Dashboard widgets** — currency converter and timezone clocks
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
#### 🤖 AI / MCP
|
#### 🤖 AI / MCP
|
||||||
|
|
||||||
- **Built-in MCP server** — OAuth 2.1 authenticated. 80+ tools, 27 resources
|
- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources
|
||||||
- **Granular scopes** — 24 OAuth scopes across 13 permission groups
|
- **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
|
- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited
|
||||||
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview`
|
||||||
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on
|
||||||
@@ -142,7 +156,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### ⚙️ Admin & customisation
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||||
- **14 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
- **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
|
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||||
|
|
||||||
@@ -162,7 +176,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
|||||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000`. The first user to register becomes 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`).
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -328,7 +342,8 @@ server {
|
|||||||
ssl_certificate /etc/ssl/fullchain.pem;
|
ssl_certificate /etc/ssl/fullchain.pem;
|
||||||
ssl_certificate_key /etc/ssl/privkey.pem;
|
ssl_certificate_key /etc/ssl/privkey.pem;
|
||||||
|
|
||||||
client_max_body_size 50m;
|
# 500 MB covers backup-restore uploads (capped at 500 MB server-side).
|
||||||
|
client_max_body_size 500m;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:3000;
|
proxy_pass http://localhost:3000;
|
||||||
@@ -345,6 +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_read_timeout 86400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -384,6 +400,7 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
|
|||||||
+1
-1
@@ -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. Email: **report@liketrek.com**
|
||||||
3. Include a description of the vulnerability and steps to reproduce
|
3. Include a description of the vulnerability and steps to reproduce
|
||||||
|
|
||||||
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
|
||||||
|
|||||||
+121
@@ -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.22
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "2.9.14"
|
appVersion: "3.0.22"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
+18
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-client",
|
"name": "@trek/client",
|
||||||
"version": "2.9.14",
|
"version": "3.0.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -12,12 +12,17 @@
|
|||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@trek/shared": "*",
|
||||||
"@react-pdf/renderer": "^4.3.2",
|
"@react-pdf/renderer": "^4.3.2",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
|
"heic-to": "^1.4.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"mapbox-gl": "^3.22.0",
|
"mapbox-gl": "^3.22.0",
|
||||||
@@ -34,6 +39,7 @@
|
|||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -56,6 +62,14 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^5.1.4",
|
"vite": "^5.1.4",
|
||||||
"vite-plugin-pwa": "^0.21.0",
|
"vite-plugin-pwa": "^0.21.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -58,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 />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ export default function App() {
|
|||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||||
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
|
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
|
|||||||
+157
-86
@@ -1,30 +1,34 @@
|
|||||||
import axios, { AxiosInstance } from 'axios'
|
import axios, { AxiosInstance } from 'axios'
|
||||||
|
import type { WeatherResult } from '@trek/shared'
|
||||||
import { getSocketId } from './websocket'
|
import { getSocketId } from './websocket'
|
||||||
import en from '../i18n/translations/en'
|
import { isReachable, probeNow } from '../sync/connectivity'
|
||||||
import br from '../i18n/translations/br'
|
const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||||
import de from '../i18n/translations/de'
|
en: 'Too many attempts. Please try again later.',
|
||||||
import es from '../i18n/translations/es'
|
de: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||||
import fr from '../i18n/translations/fr'
|
es: 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||||
import it from '../i18n/translations/it'
|
fr: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||||
import nl from '../i18n/translations/nl'
|
hu: 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||||
import pl from '../i18n/translations/pl'
|
nl: 'Te veel pogingen. Probeer het later opnieuw.',
|
||||||
import cs from '../i18n/translations/cs'
|
br: 'Muitas tentativas. Tente novamente mais tarde.',
|
||||||
import hu from '../i18n/translations/hu'
|
cs: 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||||
import ru from '../i18n/translations/ru'
|
pl: 'Zbyt wiele prób. Spróbuj ponownie później.',
|
||||||
import zh from '../i18n/translations/zh'
|
ru: 'Слишком много попыток. Попробуйте позже.',
|
||||||
import zhTw from '../i18n/translations/zhTw'
|
zh: '尝试次数过多,请稍后再试。',
|
||||||
import ar from '../i18n/translations/ar'
|
'zh-TW': '嘗試次數過多,請稍後再試。',
|
||||||
|
it: 'Troppi tentativi. Riprova più tardi.',
|
||||||
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
|
tr: 'Çok fazla deneme. Lütfen daha sonra tekrar deneyin.',
|
||||||
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
|
ar: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||||
|
id: 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||||
|
ja: '試行回数が多すぎます。時間をおいて再度お試しください。',
|
||||||
|
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||||
|
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||||
}
|
}
|
||||||
|
|
||||||
function translateRateLimit(): string {
|
function translateRateLimit(): string {
|
||||||
const fallback = 'Too many attempts. Please try again later.'
|
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||||
try {
|
try {
|
||||||
const lang = localStorage.getItem('app_language') || 'en'
|
const lang = localStorage.getItem('app_language') || 'en'
|
||||||
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
|
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||||
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
|
|
||||||
} catch {
|
} catch {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
@@ -33,6 +37,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,24 +47,24 @@ const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
|
|||||||
|
|
||||||
// Request interceptor - add socket ID + idempotency key for mutating requests
|
// Request interceptor - add socket ID + idempotency key for mutating requests
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const sid = getSocketId()
|
const sid = getSocketId()
|
||||||
if (sid) {
|
if (sid) {
|
||||||
config.headers['X-Socket-Id'] = sid
|
config.headers['X-Socket-Id'] = sid
|
||||||
}
|
}
|
||||||
// Attach a per-request idempotency key to all write operations so the
|
// Attach a per-request idempotency key to all write operations so the
|
||||||
// server can deduplicate retried requests (e.g. network blips).
|
// server can deduplicate retried requests (e.g. network blips).
|
||||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||||
const method = (config.method ?? '').toLowerCase()
|
const method = (config.method ?? '').toLowerCase()
|
||||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
? crypto.randomUUID()
|
? crypto.randomUUID()
|
||||||
: Math.random().toString(36).slice(2)
|
: Math.random().toString(36).slice(2)
|
||||||
config.headers['X-Idempotency-Key'] = key
|
config.headers['X-Idempotency-Key'] = key
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
},
|
},
|
||||||
(error) => Promise.reject(error)
|
(error) => Promise.reject(error)
|
||||||
)
|
)
|
||||||
|
|
||||||
export function isAuthPublicPath(pathname: string): boolean {
|
export function isAuthPublicPath(pathname: string): boolean {
|
||||||
@@ -68,36 +73,84 @@ export function isAuthPublicPath(pathname: string): boolean {
|
|||||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response interceptor - handle 401, 403 MFA, 429 rate limit
|
// 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
|
||||||
const { pathname } = window.location
|
},
|
||||||
if (!isAuthPublicPath(pathname)) {
|
async (error) => {
|
||||||
const currentPath = pathname + window.location.search
|
// CF Access / Pangolin / similar: cross-origin redirect from /api/* surfaces
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
// as a CORS error with no response object. Probe the health endpoint to
|
||||||
|
// distinguish a proxy auth challenge from a genuine outage. If the server
|
||||||
|
// is reachable, a top-level reload lets the edge proxy run its auth flow.
|
||||||
|
if (!error.response && navigator.onLine) {
|
||||||
|
await probeNow()
|
||||||
|
// Both the original request and the health probe failed while the device
|
||||||
|
// has a network interface. This matches the proxy-auth-challenge pattern
|
||||||
|
// (CF Access / Pangolin intercept all requests and CORS-block XHR).
|
||||||
|
// Guard with sessionStorage to prevent reload loops (server genuinely
|
||||||
|
// down would also land here, but only reloads once).
|
||||||
|
if (!isReachable()) {
|
||||||
|
const { pathname } = window.location
|
||||||
|
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||||
|
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||||
|
await unregisterSWAndReload()
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||||
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 = {
|
||||||
@@ -142,6 +195,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) */
|
||||||
@@ -153,12 +207,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[]; allows_client_credentials?: boolean }) =>
|
||||||
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),
|
||||||
},
|
},
|
||||||
@@ -215,11 +270,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 = {
|
||||||
@@ -313,7 +368,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),
|
||||||
@@ -322,7 +377,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),
|
||||||
@@ -355,10 +410,26 @@ export const journeyApi = {
|
|||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).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, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
|
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).then(r => r.data),
|
||||||
|
uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) =>
|
||||||
|
apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) },
|
||||||
|
timeout: 0,
|
||||||
|
onUploadProgress: opts?.onUploadProgress,
|
||||||
|
signal: opts?.signal,
|
||||||
|
}).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),
|
||||||
|
|
||||||
@@ -383,7 +454,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),
|
||||||
@@ -433,13 +504,13 @@ export const reservationsApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const weatherApi = {
|
export const weatherApi = {
|
||||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = {
|
||||||
@@ -525,21 +596,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
|
||||||
@@ -20,7 +20,6 @@ type Defaults = {
|
|||||||
temperature_unit?: string
|
temperature_unit?: string
|
||||||
dark_mode?: string | boolean
|
dark_mode?: string | boolean
|
||||||
time_format?: string
|
time_format?: string
|
||||||
route_calculation?: boolean
|
|
||||||
blur_booking_codes?: boolean
|
blur_booking_codes?: boolean
|
||||||
map_tile_url?: string
|
map_tile_url?: string
|
||||||
}
|
}
|
||||||
@@ -208,22 +207,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
|
||||||
{/* Route Calculation */}
|
|
||||||
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
|
||||||
{([
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
] as const).map(opt => (
|
|
||||||
<OptionButton
|
|
||||||
key={String(opt.value)}
|
|
||||||
active={defaults.route_calculation === opt.value}
|
|
||||||
onClick={() => save({ route_calculation: opt.value })}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</OptionButton>
|
|
||||||
))}
|
|
||||||
</OptionRow>
|
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||||
{([
|
{([
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
}
|
}
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
const handleRenameCategory = async (oldName, newName) => {
|
||||||
if (!newName.trim() || newName.trim() === oldName) return
|
if (!newName.trim() || newName.trim() === oldName) return
|
||||||
const items = grouped[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 = () => {
|
||||||
@@ -719,8 +719,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||||
{t('budget.title')}
|
{t('budget.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="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}
|
||||||
@@ -730,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)}
|
||||||
@@ -763,7 +763,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
|||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
>
|
>
|
||||||
<Download size={14} strokeWidth={2.5} /> CSV
|
<Download size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">CSV</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -226,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)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface MapEntry {
|
|||||||
location_name?: string | null
|
location_name?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface MapEntry {
|
|||||||
location_name?: string | null
|
location_name?: string | null
|
||||||
mood?: string | null
|
mood?: string | null
|
||||||
entry_date: string
|
entry_date: string
|
||||||
|
dayColor?: string
|
||||||
|
dayLabel?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -39,6 +41,8 @@ interface Item {
|
|||||||
label: string
|
label: string
|
||||||
locationName: string
|
locationName: string
|
||||||
time: string
|
time: string
|
||||||
|
dayColor: string
|
||||||
|
dayLabel: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const MARKER_W = 28
|
const MARKER_W = 28
|
||||||
@@ -55,6 +59,8 @@ function buildItems(entries: MapEntry[]): Item[] {
|
|||||||
label: e.title || '',
|
label: e.title || '',
|
||||||
locationName: e.location_name || '',
|
locationName: e.location_name || '',
|
||||||
time: e.entry_date,
|
time: e.entry_date,
|
||||||
|
dayColor: e.dayColor || '#52525B',
|
||||||
|
dayLabel: e.dayLabel ?? 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,21 +163,15 @@ function ensureJourneyPopupStyle() {
|
|||||||
document.head.appendChild(s)
|
document.head.appendChild(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
|
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||||
const fill = dark
|
const fill = dayColor
|
||||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
const textColor = '#fff'
|
||||||
: (highlighted ? '#18181B' : '#52525B')
|
const stroke = highlighted ? '#fff' : 'rgba(255,255,255,0.5)'
|
||||||
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
|
|
||||||
const stroke = highlighted
|
|
||||||
? (dark ? '#fff' : '#18181B')
|
|
||||||
: (dark ? '#3F3F46' : '#fff')
|
|
||||||
const shadow = highlighted
|
const shadow = highlighted
|
||||||
? (dark
|
? '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 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
|
||||||
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
|
||||||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||||
const scale = highlighted ? 1.2 : 1
|
const scale = highlighted ? 1.2 : 1
|
||||||
const label = String(index + 1)
|
const label = String(dayLabel)
|
||||||
|
|
||||||
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||||
@@ -183,7 +183,7 @@ function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDiv
|
|||||||
inner.className = 'trek-journey-marker-inner'
|
inner.className = 'trek-journey-marker-inner'
|
||||||
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
||||||
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="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="${fill}" stroke="${stroke}" stroke-width="1.5"/>
|
||||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||||
</svg>`
|
</svg>`
|
||||||
@@ -273,13 +273,12 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
const item = itemsRef.current.find(i => i.id === id)
|
const item = itemsRef.current.find(i => i.id === id)
|
||||||
const marker = markersRef.current.get(id)
|
const marker = markersRef.current.get(id)
|
||||||
if (!item || !marker) return
|
if (!item || !marker) return
|
||||||
const idx = itemsRef.current.indexOf(item)
|
|
||||||
const el = marker.getElement()
|
const el = marker.getElement()
|
||||||
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||||
if (!currentInner) return
|
if (!currentInner) return
|
||||||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||||
// would wipe mapbox's positional transform and make the marker flicker.
|
// would wipe mapbox's positional transform and make the marker flicker.
|
||||||
const next = markerHtml(idx, highlighted, !!darkRef.current)
|
const next = markerHtml(item.dayColor, item.dayLabel, highlighted)
|
||||||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||||
currentInner.style.cssText = nextInner.style.cssText
|
currentInner.style.cssText = nextInner.style.cssText
|
||||||
currentInner.innerHTML = nextInner.innerHTML
|
currentInner.innerHTML = nextInner.innerHTML
|
||||||
@@ -382,8 +381,8 @@ const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// markers
|
// markers
|
||||||
items.forEach((item, i) => {
|
items.forEach((item) => {
|
||||||
const el = markerHtml(i, false, !!darkRef.current)
|
const el = markerHtml(item.dayColor, item.dayLabel, false)
|
||||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||||
.setLngLat([item.lng, item.lat])
|
.setLngLat([item.lng, item.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
|
|||||||
@@ -1,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,20 +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
|
readOnly?: boolean
|
||||||
|
publicPhotoUrl?: (photoId: number) => string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MobileEntryView({ entry, readOnly, 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
|
||||||
@@ -49,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
<div className="fixed inset-0 z-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}>
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-100 dark:border-zinc-800 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
@@ -84,7 +87,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
{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)}
|
||||||
@@ -101,7 +104,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
{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)}
|
||||||
@@ -130,7 +133,7 @@ export default function MobileEntryView({ entry, readOnly, onClose, onEdit, onDe
|
|||||||
<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,14 +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 activeIndexRef = useRef(activeIndex)
|
|
||||||
useEffect(() => { activeIndexRef.current = activeIndex }, [activeIndex])
|
|
||||||
|
|
||||||
|
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]
|
||||||
@@ -76,29 +87,19 @@ export default function MobileMapTimeline({
|
|||||||
})
|
})
|
||||||
}, [syncMapToCarousel])
|
}, [syncMapToCarousel])
|
||||||
|
|
||||||
// Track scroll; debounce to re-center the active card when the user stops.
|
// 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 rafId: number | null = null
|
|
||||||
let settleTimer: number | null = null
|
let settleTimer: number | null = null
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (rafId != null) return
|
|
||||||
rafId = requestAnimationFrame(() => {
|
|
||||||
pickNearestCard()
|
|
||||||
rafId = null
|
|
||||||
})
|
|
||||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
settleTimer = window.setTimeout(() => {
|
settleTimer = window.setTimeout(pickNearestCard, 150)
|
||||||
// Ensure the active card sits at the center once the user settles.
|
|
||||||
const card = cardRefs.current.get(activeIndexRef.current)
|
|
||||||
card?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
|
||||||
}, 180)
|
|
||||||
}
|
}
|
||||||
el.addEventListener('scroll', onScroll, { passive: true })
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('scroll', onScroll)
|
el.removeEventListener('scroll', onScroll)
|
||||||
if (rafId != null) cancelAnimationFrame(rafId)
|
|
||||||
if (settleTimer != null) window.clearTimeout(settleTimer)
|
if (settleTimer != null) window.clearTimeout(settleTimer)
|
||||||
}
|
}
|
||||||
}, [entries.length, pickNearestCard])
|
}, [entries.length, pickNearestCard])
|
||||||
@@ -142,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}
|
||||||
@@ -168,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}
|
||||||
@@ -186,13 +193,13 @@ export default function MobileMapTimeline({
|
|||||||
{/* Bottom carousel */}
|
{/* Bottom carousel */}
|
||||||
<div
|
<div
|
||||||
className="fixed left-0 right-0 z-40"
|
className="fixed left-0 right-0 z-40"
|
||||||
style={{ touchAction: 'pan-x', bottom: 'calc(var(--bottom-nav-h, 84px) + 8px)' }}
|
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 proximity',
|
scrollSnapType: 'x mandatory',
|
||||||
WebkitOverflowScrolling: 'touch',
|
WebkitOverflowScrolling: 'touch',
|
||||||
scrollbarWidth: 'none',
|
scrollbarWidth: 'none',
|
||||||
msOverflowStyle: 'none',
|
msOverflowStyle: 'none',
|
||||||
@@ -207,7 +214,8 @@ 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={() => handleCardTap(entry, i)}
|
onClick={() => handleCardTap(entry, i)}
|
||||||
publicPhotoUrl={publicPhotoUrl}
|
publicPhotoUrl={publicPhotoUrl}
|
||||||
|
|||||||
@@ -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', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(await screen.findByText('Mes voyages')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', async () => {
|
||||||
|
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
|
||||||
|
render(<BottomNav />);
|
||||||
|
expect(await screen.findByText('Profil')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', async () => {
|
||||||
|
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(await screen.findByText('Vacances')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Atlas')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('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} />
|
||||||
|
|||||||
@@ -61,11 +61,25 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
|||||||
navigate('/login', { state: { noRedirect: true } })
|
navigate('/login', { state: { noRedirect: true } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep track of the pending theme-transition cleanup so we can cancel it
|
||||||
|
// on unmount. Without this the timer fires after jsdom teardown in unit
|
||||||
|
// tests (document is gone) and triggers an unhandled ReferenceError that
|
||||||
|
// trips vitest's exit code.
|
||||||
|
const themeTransitionTimer = useRef<number | null>(null)
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (themeTransitionTimer.current !== null) {
|
||||||
|
window.clearTimeout(themeTransitionTimer.current)
|
||||||
|
themeTransitionTimer.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = () => {
|
||||||
document.documentElement.classList.add('trek-theme-transitioning')
|
document.documentElement.classList.add('trek-theme-transitioning')
|
||||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||||
window.setTimeout(() => {
|
if (themeTransitionTimer.current !== null) window.clearTimeout(themeTransitionTimer.current)
|
||||||
|
themeTransitionTimer.current = window.setTimeout(() => {
|
||||||
document.documentElement.classList.remove('trek-theme-transitioning')
|
document.documentElement.classList.remove('trek-theme-transitioning')
|
||||||
|
themeTransitionTimer.current = null
|
||||||
}, 360)
|
}, 360)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,7 +128,8 @@ describe('MapView', () => {
|
|||||||
|
|
||||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
// Apple-Maps style draws a casing + a core line per segment.
|
||||||
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||||
@@ -152,16 +156,11 @@ describe('MapView', () => {
|
|||||||
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
expect(screen.getByTestId('cluster-group')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => {
|
||||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
|
||||||
const routeSegments = [
|
render(<MapView route={route} />)
|
||||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
// The route is drawn; per-segment times now live in the day sidebar, not on the map.
|
||||||
]
|
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||||
render(<MapView route={route} routeSegments={routeSegments} />)
|
|
||||||
// Route polyline is rendered
|
|
||||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
|
||||||
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
|
|
||||||
// so we just assert the polyline is there, exercising the routeSegments.map path
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
|
||||||
@@ -216,4 +215,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
|
||||||
}
|
}
|
||||||
@@ -225,55 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route travel time label ──
|
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
|
||||||
interface RouteLabelProps {
|
|
||||||
midpoint: [number, number]
|
|
||||||
walkingText: string
|
|
||||||
drivingText: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
|
||||||
const map = useMap()
|
|
||||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map) return
|
|
||||||
const check = () => setVisible(map.getZoom() >= 12)
|
|
||||||
check()
|
|
||||||
map.on('zoomend', check)
|
|
||||||
return () => map.off('zoomend', check)
|
|
||||||
}, [map])
|
|
||||||
|
|
||||||
if (!visible || !midpoint) return null
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: 'route-info-pill',
|
|
||||||
html: `<div style="
|
|
||||||
display:flex;align-items:center;gap:5px;
|
|
||||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
|
||||||
color:#fff;border-radius:99px;padding:3px 9px;
|
|
||||||
font-size:9px;font-weight:600;white-space:nowrap;
|
|
||||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
|
||||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
|
||||||
pointer-events:none;
|
|
||||||
position:relative;left:-50%;top:-50%;
|
|
||||||
">
|
|
||||||
<span style="display:flex;align-items:center;gap:2px">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
|
||||||
${walkingText}
|
|
||||||
</span>
|
|
||||||
<span style="opacity:0.3">|</span>
|
|
||||||
<span style="display:flex;align-items:center;gap:2px">
|
|
||||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
|
||||||
${drivingText}
|
|
||||||
</span>
|
|
||||||
</div>`,
|
|
||||||
iconSize: [0, 0],
|
|
||||||
iconAnchor: [0, 0],
|
|
||||||
})
|
|
||||||
|
|
||||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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'
|
||||||
@@ -597,23 +549,19 @@ export const MapView = memo(function MapView({
|
|||||||
{markers}
|
{markers}
|
||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
{route && route.length > 0 && (
|
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||||
<>
|
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||||
{route.map((seg, i) => seg.length > 1 && (
|
<Polyline
|
||||||
<Polyline
|
key={`${i}-casing`}
|
||||||
key={i}
|
positions={seg}
|
||||||
positions={seg}
|
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
color="#111827"
|
/>,
|
||||||
weight={3}
|
<Polyline
|
||||||
opacity={0.9}
|
key={`${i}-core`}
|
||||||
dashArray="6, 5"
|
positions={seg}
|
||||||
/>
|
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||||
))}
|
/>,
|
||||||
{routeSegments.map((seg, i) => (
|
] : [])}
|
||||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* GPX imported route geometries */}
|
{/* GPX imported route geometries */}
|
||||||
{gpxPolylines}
|
{gpxPolylines}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -132,6 +132,7 @@ export function MapViewGL({
|
|||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
route = null,
|
route = null,
|
||||||
|
routeSegments = [],
|
||||||
selectedPlaceId = null,
|
selectedPlaceId = null,
|
||||||
onMarkerClick,
|
onMarkerClick,
|
||||||
onMapClick,
|
onMapClick,
|
||||||
@@ -216,16 +217,20 @@ export function MapViewGL({
|
|||||||
// initial route source — kept around so updates can setData() cheaply
|
// initial route source — kept around so updates can setData() cheaply
|
||||||
if (!map.getSource('trip-route')) {
|
if (!map.getSource('trip-route')) {
|
||||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
// Apple-Maps style: a darker-blue casing under a bright-blue core, both
|
||||||
|
// rounded. Casing is added first so it sits beneath the core line.
|
||||||
|
map.addLayer({
|
||||||
|
id: 'trip-route-casing',
|
||||||
|
type: 'line',
|
||||||
|
source: 'trip-route',
|
||||||
|
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||||
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
|
})
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'trip-route-line',
|
id: 'trip-route-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'trip-route',
|
source: 'trip-route',
|
||||||
paint: {
|
paint: { 'line-color': '#0a84ff', 'line-width': 5 },
|
||||||
'line-color': '#111827',
|
|
||||||
'line-width': 3,
|
|
||||||
'line-opacity': 0.9,
|
|
||||||
'line-dasharray': [2, 1.5],
|
|
||||||
},
|
|
||||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -442,6 +447,8 @@ export function MapViewGL({
|
|||||||
src.setData({ type: 'FeatureCollection', features })
|
src.setData({ type: 'FeatureCollection', features })
|
||||||
}, [route])
|
}, [route])
|
||||||
|
|
||||||
|
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||||
|
|
||||||
// Update GPX geometries
|
// Update GPX geometries
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
@@ -507,13 +514,10 @@ export function MapViewGL({
|
|||||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||||
|
|
||||||
// Also fit when the places collection changes so the initial render
|
const prevFitKey = useRef(-1)
|
||||||
// zooms to the trip instead of the default center.
|
|
||||||
const placeBoundsKey = useMemo(
|
|
||||||
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
|
|
||||||
[places]
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (fitKey === prevFitKey.current) return
|
||||||
|
prevFitKey.current = fitKey
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map) return
|
if (!map) return
|
||||||
const target = dayPlaces.length > 0 ? dayPlaces : places
|
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||||
@@ -533,7 +537,7 @@ export function MapViewGL({
|
|||||||
}
|
}
|
||||||
if (map.loaded()) run()
|
if (map.loaded()) run()
|
||||||
else map.once('load', run)
|
else map.once('load', run)
|
||||||
}, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// flyTo selected place
|
// flyTo selected place
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
|
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
|
|
||||||
|
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||||
|
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||||
|
// the matching profile so walking routes follow footpaths, not the road network.
|
||||||
|
const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||||
|
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||||
|
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||||
|
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||||
|
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||||
|
const routeCache = new Map<string, RouteWithLegs>()
|
||||||
|
const ROUTE_CACHE_MAX = 200
|
||||||
|
|
||||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||||
export async function calculateRoute(
|
export async function calculateRoute(
|
||||||
waypoints: Waypoint[],
|
waypoints: Waypoint[],
|
||||||
@@ -116,12 +130,72 @@ export async function calculateSegments(
|
|||||||
const walkingDuration = leg.distance / (5000 / 3600)
|
const walkingDuration = leg.distance / (5000 / 3600)
|
||||||
return {
|
return {
|
||||||
mid, from, to,
|
mid, from, to,
|
||||||
|
distance: leg.distance,
|
||||||
|
duration: leg.duration,
|
||||||
walkingText: formatDuration(walkingDuration),
|
walkingText: formatDuration(walkingDuration),
|
||||||
drivingText: formatDuration(leg.duration),
|
drivingText: formatDuration(leg.duration),
|
||||||
|
distanceText: formatDistance(leg.distance),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One OSRM call per waypoint-run that returns BOTH the real road geometry (for the
|
||||||
|
* map) and per-leg distance/duration (for the sidebar connectors). Results are cached
|
||||||
|
* by the exact waypoint list. Throws on OSRM failure so callers can fall back to a
|
||||||
|
* straight line.
|
||||||
|
*/
|
||||||
|
export async function calculateRouteWithLegs(
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
{ signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {}
|
||||||
|
): Promise<RouteWithLegs> {
|
||||||
|
if (!waypoints || waypoints.length < 2) {
|
||||||
|
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||||
|
const cacheKey = `${profile}:${coords}`
|
||||||
|
const cached = routeCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
if (!response.ok) throw new Error('Route could not be calculated')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||||
|
|
||||||
|
const route = data.routes[0]
|
||||||
|
const coordinates: [number, number][] = route.geometry.coordinates.map(
|
||||||
|
([lng, lat]: [number, number]) => [lat, lng]
|
||||||
|
)
|
||||||
|
const legs: RouteSegment[] = (route.legs || []).map(
|
||||||
|
(leg: { distance: number; duration: number }, i: number): RouteSegment => {
|
||||||
|
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||||
|
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||||
|
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||||
|
const walkingDuration = leg.distance / (5000 / 3600)
|
||||||
|
return {
|
||||||
|
mid, from, to,
|
||||||
|
distance: leg.distance,
|
||||||
|
duration: leg.duration,
|
||||||
|
walkingText: formatDuration(walkingDuration),
|
||||||
|
drivingText: formatDuration(leg.duration),
|
||||||
|
distanceText: formatDistance(leg.distance),
|
||||||
|
durationText: formatDuration(leg.duration),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||||
|
routeCache.set(cacheKey, result)
|
||||||
|
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||||
|
const oldest = routeCache.keys().next().value
|
||||||
|
if (oldest !== undefined) routeCache.delete(oldest)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
function formatDistance(meters: number): string {
|
function formatDistance(meters: number): string {
|
||||||
if (meters < 1000) {
|
if (meters < 1000) {
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ export function isStandardFamily(style: string): boolean {
|
|||||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
// Terrain is only genuinely useful for styles that benefit from elevation
|
||||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
// data. On flat vector styles (streets/light/dark) it nudges route lines
|
||||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
// onto the DEM while HTML markers stay at Z=0, causing a visible drift
|
||||||
// offset when the map is pitched. Restrict terrain to satellite.
|
// when the map is pitched. Satellite and Outdoors are the intended styles
|
||||||
|
// for terrain; markers are re-pinned by syncMarkerAltitudes().
|
||||||
export function wantsTerrain(style: string): boolean {
|
export function wantsTerrain(style: string): boolean {
|
||||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||||
|
|| style === 'mapbox://styles/mapbox/outdoors-v12'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3D can be added to every style now — the standard family has it built-in
|
// 3D can be added to every style now — the standard family has it built-in
|
||||||
|
|||||||
@@ -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,8 @@ 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'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||||
if (!_renderToStaticMarkup) return ''
|
if (!_renderToStaticMarkup) return ''
|
||||||
@@ -96,12 +98,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 +142,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 +214,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 = splitReservationDateTime(displayTime).time ?? ''
|
||||||
|
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 +287,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
|
||||||
|
|||||||
@@ -8,7 +8,21 @@ import { useAuthStore } from '../../store/authStore';
|
|||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||||
import PackingListPanel from './PackingListPanel';
|
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
||||||
|
|
||||||
|
describe('itemWeight (bag total weight calc)', () => {
|
||||||
|
it('FE-COMP-PACKING-030: multiplies unit weight by quantity', () => {
|
||||||
|
expect(itemWeight({ weight_grams: 120, quantity: 3 })).toBe(360);
|
||||||
|
});
|
||||||
|
it('FE-COMP-PACKING-031: defaults quantity to 1 when missing', () => {
|
||||||
|
expect(itemWeight({ weight_grams: 250 })).toBe(250);
|
||||||
|
});
|
||||||
|
it('FE-COMP-PACKING-032: contributes 0 when weight is missing or zero', () => {
|
||||||
|
expect(itemWeight({ quantity: 5 })).toBe(0);
|
||||||
|
expect(itemWeight({ weight_grams: 0, quantity: 5 })).toBe(0);
|
||||||
|
expect(itemWeight({})).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ function katColor(kat, allCategories) {
|
|||||||
|
|
||||||
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
interface PackingBag { id: number; trip_id: number; name: string; color: string; weight_limit_grams: number | null; user_id?: number | null; assigned_username?: string | null }
|
||||||
|
|
||||||
|
/** Weight an item contributes to a total: unit weight times quantity (defaults: 0 g, qty 1). */
|
||||||
|
export const itemWeight = (i: { weight_grams?: number | null; quantity?: number | null }): number =>
|
||||||
|
(i.weight_grams || 0) * (i.quantity || 1)
|
||||||
|
|
||||||
// ── Bag Card ──────────────────────────────────────────────────────────────
|
// ── Bag Card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface BagCardProps {
|
interface BagCardProps {
|
||||||
@@ -208,9 +212,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 +232,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')) }
|
||||||
}
|
}
|
||||||
@@ -275,9 +284,10 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
{editing && canEdit ? (
|
{editing && canEdit ? (
|
||||||
<input
|
<input
|
||||||
type="text" value={editName} autoFocus
|
type="text" value={editName} autoFocus
|
||||||
|
placeholder={isPlaceholder ? '...' : undefined}
|
||||||
onChange={e => setEditName(e.target.value)}
|
onChange={e => setEditName(e.target.value)}
|
||||||
onBlur={handleSaveName}
|
onBlur={handleSaveName}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(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' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -286,7 +296,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingE
|
|||||||
style={{
|
style={{
|
||||||
flex: 1, fontSize: 13.5,
|
flex: 1, fontSize: 13.5,
|
||||||
cursor: !canEdit || item.checked ? 'default' : 'text',
|
cursor: !canEdit || item.checked ? 'default' : 'text',
|
||||||
color: 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)',
|
transition: 'color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||||
textDecoration: item.checked ? 'line-through' : 'none',
|
textDecoration: item.checked ? 'line-through' : 'none',
|
||||||
}}
|
}}
|
||||||
@@ -1305,8 +1315,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
|
|
||||||
{bags.map(bag => {
|
{bags.map(bag => {
|
||||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||||
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
const maxWeight = bag.weight_limit_grams || Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
return (
|
return (
|
||||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
||||||
@@ -1316,7 +1326,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
{/* Unassigned */}
|
{/* Unassigned */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const unassigned = items.filter(i => !i.bag_id)
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||||
if (unassigned.length === 0) return null
|
if (unassigned.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
||||||
@@ -1336,7 +1346,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
<span>{t('packing.totalWeight')}</span>
|
<span>{t('packing.totalWeight')}</span>
|
||||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1374,8 +1384,8 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
|
|
||||||
{bags.map(bag => {
|
{bags.map(bag => {
|
||||||
const bagItems = items.filter(i => i.bag_id === bag.id)
|
const bagItems = items.filter(i => i.bag_id === bag.id)
|
||||||
const totalWeight = bagItems.reduce((sum, i) => sum + (i.weight_grams || 0), 0)
|
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||||
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + (i.weight_grams || 0), 0)), 1)
|
const maxWeight = Math.max(...bags.map(b => items.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||||
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
const pct = Math.min(100, Math.round((totalWeight / maxWeight) * 100))
|
||||||
return (
|
return (
|
||||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||||
@@ -1385,7 +1395,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
{/* Unassigned */}
|
{/* Unassigned */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const unassigned = items.filter(i => !i.bag_id)
|
const unassigned = items.filter(i => !i.bag_id)
|
||||||
const unassignedWeight = unassigned.reduce((s, i) => s + (i.weight_grams || 0), 0)
|
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||||
if (unassigned.length === 0) return null
|
if (unassigned.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
||||||
@@ -1405,7 +1415,7 @@ export default function PackingListPanel({ tripId, items, openImportSignal = 0,
|
|||||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
<span>{t('packing.totalWeight')}</span>
|
<span>{t('packing.totalWeight')}</span>
|
||||||
<span>{(() => { const w = items.reduce((s, i) => s + (i.weight_grams || 0), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
<span>{(() => { const w = items.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||||
</div>
|
</div>
|
||||||
</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,8 @@ 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'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
const WEATHER_ICON_MAP = {
|
const WEATHER_ICON_MAP = {
|
||||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||||
@@ -56,9 +58,10 @@ interface DayDetailPanelProps {
|
|||||||
rightWidth?: number
|
rightWidth?: number
|
||||||
collapsed?: boolean
|
collapsed?: boolean
|
||||||
onToggleCollapse?: () => void
|
onToggleCollapse?: () => void
|
||||||
|
mobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false }: DayDetailPanelProps) {
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const tripObj = useTripStore((s) => s.trip)
|
const tripObj = useTripStore((s) => s.trip)
|
||||||
@@ -66,7 +69,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 +102,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 +133,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 +157,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 +175,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))`, ...(mobile ? { zIndex: 10000 } : null), ...font }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'var(--bg-elevated)',
|
background: 'var(--bg-elevated)',
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
@@ -283,7 +290,11 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
{/* ── Reservations for this day's assignments ── */}
|
{/* ── Reservations for this day's assignments ── */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAssignments = assignments[String(day.id)] || []
|
const dayAssignments = assignments[String(day.id)] || []
|
||||||
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
|
const dayReservations = reservations.filter(r => {
|
||||||
|
if (r.type === 'hotel') return false
|
||||||
|
if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true
|
||||||
|
return r.day_id === day.id
|
||||||
|
})
|
||||||
if (dayReservations.length === 0) return null
|
if (dayReservations.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ marginBottom: 0 }}>
|
<div style={{ marginBottom: 0 }}>
|
||||||
@@ -300,12 +311,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
|||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{r.reservation_time?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
const { time: startTime } = splitReservationDateTime(r.reservation_time)
|
||||||
{new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
const { time: endTime } = splitReservationDateTime(r.reservation_end_time)
|
||||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
if (!startTime && !endTime) return null
|
||||||
</span>
|
return (
|
||||||
)}
|
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||||
|
{startTime ? formatTime12(startTime, is12h) : ''}
|
||||||
|
{endTime ? ` – ${formatTime12(endTime, is12h)}` : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -459,10 +475,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 +490,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 +610,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?.()
|
||||||
|
|||||||
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
// Find the pencil/edit button next to the title
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const editButtons = screen.getAllByRole('button')
|
|
||||||
const editBtn = editButtons.find(btn => btn.querySelector('svg') && btn.closest('[style]')?.textContent?.includes('Original Title'))
|
|
||||||
// Click the edit (pencil) button — it's the small one near the title
|
|
||||||
// The pencil button is inside the title area with opacity 0.35
|
|
||||||
const titleEl = screen.getByText('Original Title')
|
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const onUpdateDayTitle = vi.fn()
|
const onUpdateDayTitle = vi.fn()
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||||
// Enter edit mode
|
// Enter edit mode
|
||||||
const titleEl = screen.getByText('Original Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Original Title')
|
const input = await screen.findByDisplayValue('Original Title')
|
||||||
await user.clear(input)
|
await user.clear(input)
|
||||||
await user.type(input, 'New Title')
|
await user.type(input, 'New Title')
|
||||||
@@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||||
const titleEl = screen.getByText('Original Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Original Title')
|
const input = await screen.findByDisplayValue('Original Title')
|
||||||
await user.keyboard('{Escape}')
|
await user.keyboard('{Escape}')
|
||||||
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
|
||||||
@@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
const onUpdateDayTitle = vi.fn()
|
const onUpdateDayTitle = vi.fn()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||||
const titleEl = screen.getByText('Old Title')
|
await user.click(screen.getByLabelText('Edit'))
|
||||||
const pencilBtn = titleEl.parentElement?.querySelector('button')
|
|
||||||
if (pencilBtn) await user.click(pencilBtn)
|
|
||||||
const input = await screen.findByDisplayValue('Old Title')
|
const input = await screen.findByDisplayValue('Old Title')
|
||||||
await user.clear(input)
|
await user.clear(input)
|
||||||
await user.type(input, 'New Title')
|
await user.type(input, 'New Title')
|
||||||
|
|||||||
@@ -2,18 +2,19 @@
|
|||||||
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, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Footprints, 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'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
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,10 +22,16 @@ 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 { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
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, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import Tooltip from '../shared/Tooltip'
|
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, RouteSegment } from '../../types'
|
||||||
|
|
||||||
const NOTE_ICONS = [
|
const NOTE_ICONS = [
|
||||||
{ id: 'FileText', Icon: FileText },
|
{ id: 'FileText', Icon: FileText },
|
||||||
@@ -177,6 +184,10 @@ interface DayPlanSidebarProps {
|
|||||||
onExternalTransportDetailHandled?: () => void
|
onExternalTransportDetailHandled?: () => void
|
||||||
onAddReservation: () => void
|
onAddReservation: () => void
|
||||||
onNavigateToFiles?: () => void
|
onNavigateToFiles?: () => void
|
||||||
|
routeShown?: boolean
|
||||||
|
routeProfile?: 'driving' | 'walking'
|
||||||
|
onToggleRoute?: () => void
|
||||||
|
onSetRouteProfile?: (profile: 'driving' | 'walking') => void
|
||||||
onAddPlace?: () => void
|
onAddPlace?: () => void
|
||||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||||
@@ -189,6 +200,27 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||||
|
function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: 'driving' | 'walking' }) {
|
||||||
|
const driving = profile === 'driving'
|
||||||
|
const Icon = driving ? Car : Footprints
|
||||||
|
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||||
|
<div style={line} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||||
|
<Icon size={11} strokeWidth={2} />
|
||||||
|
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||||
|
<span style={{ opacity: 0.4 }}>·</span>
|
||||||
|
<span>{seg.distanceText}</span>
|
||||||
|
</div>
|
||||||
|
<div style={line} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||||
@@ -207,6 +239,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onAddPlace,
|
onAddPlace,
|
||||||
onAddPlaceToDay,
|
onAddPlaceToDay,
|
||||||
onNavigateToFiles,
|
onNavigateToFiles,
|
||||||
|
routeShown = false,
|
||||||
|
routeProfile = 'driving',
|
||||||
|
onToggleRoute,
|
||||||
|
onSetRouteProfile,
|
||||||
onExpandedDaysChange,
|
onExpandedDaysChange,
|
||||||
pushUndo,
|
pushUndo,
|
||||||
canUndo = false,
|
canUndo = false,
|
||||||
@@ -217,6 +253,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()
|
||||||
@@ -240,6 +278,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const [editTitle, setEditTitle] = useState('')
|
const [editTitle, setEditTitle] = useState('')
|
||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
|
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||||
|
const legsAbortRef = useRef<AbortController | null>(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [lockedIds, setLockedIds] = useState(new Set())
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
const [lockHoverId, setLockHoverId] = useState(null)
|
const [lockHoverId, setLockHoverId] = useState(null)
|
||||||
@@ -269,6 +309,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const dragDataRef = useRef(null)
|
const dragDataRef = useRef(null)
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (scrollContainerRef.current && initialScrollTop) {
|
||||||
|
scrollContainerRef.current.scrollTop = initialScrollTop
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||||
// Remember which assignment we last auto-scrolled into view so we don't
|
// Remember which assignment we last auto-scrolled into view so we don't
|
||||||
// keep yanking the user back whenever they scroll away while the same
|
// keep yanking the user back whenever they scroll away while the same
|
||||||
@@ -350,26 +396,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
|
||||||
@@ -394,27 +420,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) => {
|
||||||
@@ -434,20 +441,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
|
||||||
@@ -489,64 +482,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),
|
||||||
// All places keep their order_index — untimed can be freely moved, timed auto-sort when time is set
|
dayId,
|
||||||
const baseItems = [
|
getDisplayTime: getDisplayTimeForDay,
|
||||||
...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
|
||||||
@@ -558,6 +501,42 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
}, [days, assignments, dayNotes, reservations, transportPosVersion])
|
||||||
|
|
||||||
|
// Per-segment driving times for the selected day's connectors. Groups located
|
||||||
|
// places into runs (split at transports), one cached OSRM call per run, keyed by
|
||||||
|
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||||
|
useEffect(() => {
|
||||||
|
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||||
|
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||||
|
const merged = mergedItemsMap[selectedDayId] || []
|
||||||
|
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||||
|
let cur: { id: number; lat: number; lng: number }[] = []
|
||||||
|
for (const it of merged) {
|
||||||
|
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||||
|
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||||
|
} else if (it.type === 'transport') {
|
||||||
|
if (cur.length >= 2) runs.push(cur)
|
||||||
|
cur = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur.length >= 2) runs.push(cur)
|
||||||
|
if (runs.length === 0) { setRouteLegs({}); return }
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
legsAbortRef.current = controller
|
||||||
|
;(async () => {
|
||||||
|
const map: Record<number, RouteSegment> = {}
|
||||||
|
for (const run of runs) {
|
||||||
|
try {
|
||||||
|
const r = await calculateRouteWithLegs(run.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: routeProfile })
|
||||||
|
r.legs.forEach((leg, i) => { map[run[i].id] = leg })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!controller.signal.aborted) setRouteLegs(map)
|
||||||
|
})()
|
||||||
|
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
_openAddNote(dayId, getMergedItems, (id) => {
|
_openAddNote(dayId, getMergedItems, (id) => {
|
||||||
@@ -878,13 +857,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGoogleMaps = () => {
|
|
||||||
if (!selectedDayId) return
|
|
||||||
const da = getDayAssignments(selectedDayId)
|
|
||||||
const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng))
|
|
||||||
if (url) window.open(url, '_blank')
|
|
||||||
else toast.error(t('dayplan.toast.noGeoPlaces'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDropOnDay = (e, dayId) => {
|
const handleDropOnDay = (e, dayId) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -1116,7 +1088,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tagesliste */}
|
{/* Tagesliste */}
|
||||||
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<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)
|
||||||
@@ -1133,6 +1105,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||||
<div
|
<div
|
||||||
|
className="dp-day-header"
|
||||||
|
data-selected={isSelected}
|
||||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); 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) }}
|
||||||
@@ -1152,16 +1126,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
{/* Tages-Badge */}
|
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
|
||||||
<div style={{
|
{(() => {
|
||||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
return (
|
||||||
fontSize: 11, fontWeight: 700,
|
<div style={{
|
||||||
}}>
|
flexShrink: 0, alignSelf: 'flex-start',
|
||||||
{index + 1}
|
width: hasWeather ? 34 : 26,
|
||||||
</div>
|
borderRadius: hasWeather ? 11 : '50%',
|
||||||
|
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
||||||
|
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700 }}>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
{hasWeather && (
|
||||||
|
<>
|
||||||
|
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
|
||||||
|
<div style={{ padding: '3px 0 4px' }}>
|
||||||
|
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{editingDayId === day.id ? (
|
{editingDayId === day.id ? (
|
||||||
@@ -1179,42 +1171,29 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
borderBottom: '1.5px solid var(--text-primary)',
|
borderBottom: '1.5px solid var(--text-primary)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||||
</span>
|
</span>
|
||||||
{canEditDays && <button
|
{formattedDate && (
|
||||||
onClick={e => startEditTitle(day, e)}
|
<>
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
<span style={{ flexShrink: 0, width: 1, height: 11, background: 'var(--border-primary)' }} />
|
||||||
>
|
<span style={{ flexShrink: 0, fontSize: 11, fontWeight: 400, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
|
||||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
{formattedDate}
|
||||||
</button>}
|
</span>
|
||||||
{canEditDays && onAddTransport && (
|
</>
|
||||||
<Tooltip label={t('transport.addTransport')} placement="top">
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
|
||||||
aria-label={t('transport.addTransport')}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
padding: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
opacity: 0.45,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: 4,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
|
|
||||||
>
|
|
||||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||||
|
const hasRentals = getActiveRentalsForDay(day.id).length > 0
|
||||||
|
if (!hasAccs && !hasRentals) return null
|
||||||
|
return <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
|
||||||
|
})()}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
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
|
||||||
@@ -1231,13 +1210,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return dayAccs.map(acc => {
|
return dayAccs.map(acc => {
|
||||||
const isCheckIn = acc.start_day_id === day.id
|
const isCheckIn = acc.start_day_id === day.id
|
||||||
const isCheckOut = acc.end_day_id === day.id
|
const isCheckOut = acc.end_day_id === day.id
|
||||||
const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)'
|
const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
|
||||||
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)'
|
|
||||||
return (
|
return (
|
||||||
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
|
<span key={acc.id} onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
|
||||||
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
|
<Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1247,41 +1224,50 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const activeRentals = getActiveRentalsForDay(day.id)
|
const activeRentals = getActiveRentalsForDay(day.id)
|
||||||
if (activeRentals.length === 0) return null
|
if (activeRentals.length === 0) return null
|
||||||
return activeRentals.map(r => (
|
return activeRentals.map(r => (
|
||||||
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
|
<span key={`rental-${r.id}`} onClick={e => { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}>
|
||||||
<Car size={8} style={{ color: '#3b82f6', flexShrink: 0 }} />
|
<Car size={11} strokeWidth={1.8} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||||
</span>
|
</span>
|
||||||
))
|
))
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{cost && (
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
<span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
|
||||||
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
|
|
||||||
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
|
|
||||||
{day.date && anyGeoPlace && <span style={{ width: 1, height: 10, background: 'var(--text-faint)', opacity: 0.3, flexShrink: 0 }} />}
|
|
||||||
{day.date && anyGeoPlace && (() => {
|
|
||||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
|
||||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
|
||||||
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
{canEditDays ? (
|
||||||
onClick={e => openAddNote(day.id, e)}
|
(() => {
|
||||||
aria-label={t('dayplan.addNote')}
|
const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
const div = '1px solid var(--border-faint)'
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
return (
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
<div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
|
||||||
>
|
<button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||||
<FileText size={16} strokeWidth={2} />
|
<Pencil size={14} strokeWidth={1.8} />
|
||||||
</button></Tooltip>}
|
</button>
|
||||||
<button
|
{onAddTransport ? (
|
||||||
onClick={e => toggleDay(day.id, e)}
|
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
<Plus size={14} strokeWidth={1.8} />
|
||||||
>
|
</button>
|
||||||
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
|
) : <div style={{ borderBottom: div }} />}
|
||||||
</button>
|
<button onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
|
||||||
|
<FileText size={14} strokeWidth={1.8} />
|
||||||
|
</button>
|
||||||
|
<button onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
|
||||||
|
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<button onClick={e => toggleDay(day.id, e)} style={{ alignSelf: 'flex-start', flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||||
|
{isExpanded ? <ChevronDown size={16} strokeWidth={1.8} /> : <ChevronRight size={16} strokeWidth={1.8} />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aufgeklappte Orte + Notizen */}
|
{/* Aufgeklappte Orte + Notizen */}
|
||||||
@@ -1573,12 +1559,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
}}>
|
}}>
|
||||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||||
{res.reservation_time?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ fontWeight: 400 }}>
|
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
if (!st && !et) return null
|
||||||
</span>
|
return (
|
||||||
)}
|
<span style={{ fontWeight: 400 }}>
|
||||||
|
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||||
|
{et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{(() => {
|
{(() => {
|
||||||
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) return null
|
if (!meta) return null
|
||||||
@@ -1688,6 +1679,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{routeLegs[assignment.id] && <RouteConnector seg={routeLegs[assignment.id]} profile={routeProfile} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1722,7 +1714,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
return (
|
return (
|
||||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||||
<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()
|
||||||
@@ -1733,6 +1729,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
draggable={canEditDays && spanPhase !== 'middle'}
|
draggable={canEditDays && spanPhase !== 'middle'}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||||
|
// setData is required for the drag to start reliably (Firefox) and
|
||||||
|
// matches how place/note items initiate their drag.
|
||||||
|
e.dataTransfer.setData('reservationId', String(res.id))
|
||||||
|
e.dataTransfer.setData('fromDayId', String(day.id))
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
||||||
setDraggingId(res.id)
|
setDraggingId(res.id)
|
||||||
@@ -1801,18 +1801,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{res.title}
|
{res.title}
|
||||||
</span>
|
</span>
|
||||||
{displayTime?.includes('T') && (
|
{(() => {
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
const { time: dispTime } = splitReservationDateTime(displayTime)
|
||||||
<Clock size={9} strokeWidth={2} />
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
{new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
if (!dispTime && !endTime) return null
|
||||||
{spanPhase === 'single' && res.reservation_end_time && (() => {
|
return (
|
||||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
||||||
return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
|
<Clock size={9} strokeWidth={2} />
|
||||||
})()}
|
{dispTime ? formatTime(dispTime, locale, timeFormat) : ''}
|
||||||
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
{spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||||
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
|
||||||
</span>
|
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
|
||||||
)}
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
@@ -1861,8 +1863,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||||
if (fromReservationId && fromDayId !== day.id) {
|
if (placeId) {
|
||||||
|
// New place dropped onto a note: insert it among the
|
||||||
|
// assignments at the note's position (after the places
|
||||||
|
// above it), so it lands right where the note sits.
|
||||||
|
const tm = getMergedItems(day.id)
|
||||||
|
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||||
|
const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||||
|
onAssignToDay?.(parseInt(placeId), day.id, pos)
|
||||||
|
setDropTargetKey(null); window.__dragData = null
|
||||||
|
} else if (fromReservationId && fromDayId !== day.id) {
|
||||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
@@ -1959,7 +1970,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||||
}
|
}
|
||||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||||
if (assignmentId && fromDayId !== day.id) {
|
if (assignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||||
@@ -1975,6 +1986,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
|
||||||
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
|
else if (fromReservationId && String(lastItem?.data?.id) !== fromReservationId)
|
||||||
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), lastItem.type, lastItem.data.id, true)
|
||||||
|
setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{dropTargetKey === `end-${day.id}` && (
|
{dropTargetKey === `end-${day.id}` && (
|
||||||
@@ -1985,15 +1999,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
{routeInfo && (
|
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
<button
|
||||||
<span>{routeInfo.distance}</span>
|
onClick={() => onToggleRoute?.()}
|
||||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
style={{
|
||||||
<span>{routeInfo.duration}</span>
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
</div>
|
padding: '6px 0', fontSize: 11, fontWeight: 600, borderRadius: 8,
|
||||||
)}
|
border: routeShown ? 'none' : '1px solid var(--border-faint)',
|
||||||
|
background: routeShown ? 'var(--accent)' : 'transparent',
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
color: routeShown ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RouteIcon size={12} strokeWidth={2} />
|
||||||
|
{t('dayplan.route')}
|
||||||
|
</button>
|
||||||
<button onClick={handleOptimize} style={{
|
<button onClick={handleOptimize} style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
@@ -2002,14 +2022,35 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<RotateCcw size={12} strokeWidth={2} />
|
<RotateCcw size={12} strokeWidth={2} />
|
||||||
{t('dayplan.optimize')}
|
{t('dayplan.optimize')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleGoogleMaps} style={{
|
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
{(['driving', 'walking'] as const).map(p => {
|
||||||
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
|
const ModeIcon = p === 'driving' ? Car : Footprints
|
||||||
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
const active = routeProfile === p
|
||||||
}}>
|
return (
|
||||||
<ExternalLink size={12} strokeWidth={2} />
|
<button
|
||||||
</button>
|
key={p}
|
||||||
|
onClick={() => onSetRouteProfile?.(p)}
|
||||||
|
aria-label={p === 'driving' ? 'Driving' : 'Walking'}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||||||
|
background: active ? 'var(--accent)' : 'transparent',
|
||||||
|
color: active ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModeIcon size={13} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{routeInfo && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||||
|
<span>{routeInfo.distance}</span>
|
||||||
|
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
||||||
|
<span>{routeInfo.duration}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2173,13 +2214,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{res.title}</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
|
||||||
{res.reservation_time?.includes('T')
|
{(() => {
|
||||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
const { date, time } = splitReservationDateTime(res.reservation_time)
|
||||||
: res.reservation_time
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const dateStr = date
|
||||||
|
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
: ''
|
: ''
|
||||||
}
|
const timeStr = time ? formatTime(time, locale, timeFormat) : ''
|
||||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
const endStr = endTime ? formatTime(endTime, locale, timeFormat) : ''
|
||||||
|
const parts: string[] = []
|
||||||
|
if (dateStr) parts.push(dateStr)
|
||||||
|
if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : ''))
|
||||||
|
return parts.join(', ')
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -2220,7 +2267,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'
|
||||||
@@ -9,6 +10,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||||
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
|
||||||
const detailsCache = new Map()
|
const detailsCache = new Map()
|
||||||
|
|
||||||
@@ -168,7 +170,10 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
const category = categories?.find(c => c.id === place.category_id)
|
const category = categories?.find(c => c.id === place.category_id)
|
||||||
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
||||||
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
|
const assignmentInDay = selectedDayId
|
||||||
|
? ((selectedAssignmentId ? dayAssignments.find(a => a.id === selectedAssignmentId) : null)
|
||||||
|
?? dayAssignments.find(a => a.place?.id === place.id))
|
||||||
|
: null
|
||||||
|
|
||||||
const openingHours = googleDetails?.opening_hours || null
|
const openingHours = googleDetails?.opening_hours || null
|
||||||
const openNow = googleDetails?.open_now ?? null
|
const openNow = googleDetails?.open_now ?? null
|
||||||
@@ -343,14 +348,14 @@ export default function PlaceInspector({
|
|||||||
{/* Description / Summary */}
|
{/* Description / Summary */}
|
||||||
{(place.description || googleDetails?.summary) && (
|
{(place.description || googleDetails?.summary) && (
|
||||||
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -377,21 +382,29 @@ export default function PlaceInspector({
|
|||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
{res.reservation_time && (
|
{(() => {
|
||||||
<div>
|
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
return (
|
||||||
</div>
|
<>
|
||||||
)}
|
{date && (
|
||||||
{res.reservation_time?.includes('T') && (
|
<div>
|
||||||
<div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
</div>
|
||||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
)}
|
||||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
{(startTime || endTime) && (
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||||
)}
|
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||||
|
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||||
|
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
{res.confirmation_number && (
|
{res.confirmation_number && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||||
@@ -399,7 +412,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
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -34,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 {
|
||||||
@@ -145,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()
|
||||||
@@ -159,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
|
||||||
@@ -636,7 +645,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Liste */}
|
{/* Liste */}
|
||||||
<div className="trek-stagger" 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -182,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
|
||||||
@@ -194,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: [],
|
||||||
@@ -271,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 */}
|
||||||
@@ -417,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={[
|
||||||
@@ -437,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>
|
||||||
@@ -447,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>
|
||||||
@@ -601,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
|||||||
resetAllStores();
|
resetAllStores();
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ReservationsPanel', () => {
|
describe('ReservationsPanel', () => {
|
||||||
@@ -211,7 +211,7 @@ describe('ReservationsPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => {
|
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => {
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||||
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
||||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||||
const codeEl = screen.getByText('ABC123');
|
const codeEl = screen.getByText('ABC123');
|
||||||
@@ -220,7 +220,7 @@ describe('ReservationsPanel', () => {
|
|||||||
|
|
||||||
it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => {
|
it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||||
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
||||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||||
const codeEl = screen.getByText('ABC123');
|
const codeEl = screen.getByText('ABC123');
|
||||||
@@ -389,4 +389,51 @@ describe('ReservationsPanel', () => {
|
|||||||
expect(screen.getByText('Pending 2')).toBeInTheDocument();
|
expect(screen.getByText('Pending 2')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Pending 3')).toBeInTheDocument();
|
expect(screen.getByText('Pending 3')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
|
||||||
|
const day = buildDay({ date: null, day_number: 25 } as any);
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Cruise test',
|
||||||
|
type: 'cruise',
|
||||||
|
status: 'pending',
|
||||||
|
reservation_time: 'T10:00',
|
||||||
|
reservation_end_time: 'T18:00',
|
||||||
|
day_id: day.id,
|
||||||
|
end_day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/10:00/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
|
||||||
|
const day = buildDay({ date: null, day_number: 3 } as any);
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Car rental',
|
||||||
|
type: 'car',
|
||||||
|
status: 'pending',
|
||||||
|
reservation_time: '09:00',
|
||||||
|
reservation_end_time: '17:00',
|
||||||
|
day_id: day.id,
|
||||||
|
end_day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/09:00/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
|
||||||
|
const day = buildDay({ date: '2026-07-15', day_number: 1 });
|
||||||
|
const r = buildReservation({
|
||||||
|
title: 'Flight out',
|
||||||
|
type: 'flight',
|
||||||
|
status: 'confirmed',
|
||||||
|
reservation_time: '2026-07-15T08:30',
|
||||||
|
reservation_end_time: '2026-07-15T10:45',
|
||||||
|
day_id: day.id,
|
||||||
|
} as any);
|
||||||
|
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
|
||||||
|
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/08:30/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ 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'
|
||||||
|
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
|
||||||
|
|
||||||
interface AssignmentLookupEntry {
|
interface AssignmentLookupEntry {
|
||||||
dayNumber: number
|
dayNumber: number
|
||||||
@@ -96,33 +100,42 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
const fmtDate = (str) => {
|
const startDt = splitReservationDateTime(r.reservation_time)
|
||||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
const endDt = splitReservationDateTime(r.reservation_end_time)
|
||||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
const fmtDate = (date: string) =>
|
||||||
}
|
new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||||
const fmtTime = (str) => {
|
|
||||||
const d = new Date(str)
|
|
||||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDate = !!r.reservation_time
|
const hasDate = !!startDt.date
|
||||||
const hasTime = r.reservation_time?.includes('T')
|
const hasTime = !!(startDt.time || endDt.time)
|
||||||
const hasCode = !!r.confirmation_number
|
const hasCode = !!r.confirmation_number
|
||||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||||
|
|
||||||
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 +148,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,32 +217,38 @@ 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>
|
||||||
)}
|
)}
|
||||||
{/* Date / Time row */}
|
{/* Date / Time row */}
|
||||||
{hasDate && (
|
{(hasDate || hasTime) && (
|
||||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}>
|
||||||
<div>
|
{hasDate && (
|
||||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
<div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||||
{fmtDate(r.reservation_time)}
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{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] && (
|
{fmtDate(startDt.date!)}
|
||||||
<> – {fmtDate(r.reservation_end_time)}</>
|
{endDt.date && endDt.date !== startDt.date && (
|
||||||
)}
|
<> – {fmtDate(endDt.date)}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{hasTime && (
|
{hasTime && (
|
||||||
<div>
|
<div>
|
||||||
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
<div style={fieldLabelStyle}>{t('reservations.time')}</div>
|
||||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
|
{formatTime(startDt.time, locale, timeFormat)}
|
||||||
|
{endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -286,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||||
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||||
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||||
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') })
|
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
|
||||||
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) })
|
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
|
||||||
if (cells.length === 0) return null
|
if (cells.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: cells.length > 1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}>
|
||||||
@@ -337,7 +358,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 { formatDate } from '../../utils/formatters'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
|
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||||
|
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
|
||||||
@@ -117,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
status: reservation.status || 'pending',
|
status: reservation.status || 'pending',
|
||||||
start_day_id: reservation.day_id ?? '',
|
start_day_id: reservation.day_id ?? '',
|
||||||
end_day_id: reservation.end_day_id ?? '',
|
end_day_id: reservation.end_day_id ?? '',
|
||||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '',
|
||||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '',
|
||||||
confirmation_number: reservation.confirmation_number || '',
|
confirmation_number: reservation.confirmation_number || '',
|
||||||
notes: reservation.notes || '',
|
notes: reservation.notes || '',
|
||||||
meta_airline: meta.airline || '',
|
meta_airline: meta.airline || '',
|
||||||
@@ -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 })
|
||||||
@@ -139,7 +165,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
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 }))
|
||||||
|
|
||||||
@@ -153,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
|
|
||||||
const buildTime = (day: Day | undefined, time: string): string | null => {
|
const buildTime = (day: Day | undefined, time: string): string | null => {
|
||||||
if (!time) return null
|
if (!time) return null
|
||||||
return day?.date ? `${day.date}T${time}` : `T${time}`
|
return day?.date ? `${day.date}T${time}` : time
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata: Record<string, string> = {}
|
const metadata: Record<string, string> = {}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -155,33 +155,12 @@ 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');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-021: shows Route Calculation section', () => {
|
|
||||||
render(<DisplaySettingsTab />);
|
|
||||||
expect(screen.getByText(/route calculation/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-022: route calculation On button is active when route_calculation is true', () => {
|
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }) });
|
|
||||||
render(<DisplaySettingsTab />);
|
|
||||||
const onButtons = screen.getAllByText(/^On$/i);
|
|
||||||
const routeCalcOnBtn = onButtons[0].closest('button')!;
|
|
||||||
expect(routeCalcOnBtn.style.border).toContain('var(--text-primary)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-023: clicking route calculation Off calls updateSetting with false', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }), updateSetting });
|
|
||||||
render(<DisplaySettingsTab />);
|
|
||||||
const offButtons = screen.getAllByText(/^Off$/i);
|
|
||||||
await user.click(offButtons[0]);
|
|
||||||
expect(updateSetting).toHaveBeenCalledWith('route_calculation', false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
|
it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
|
||||||
render(<DisplaySettingsTab />);
|
render(<DisplaySettingsTab />);
|
||||||
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
|
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -188,8 +188,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{[
|
{[
|
||||||
{ value: '24h', 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}
|
||||||
@@ -207,37 +207,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
transition: 'all 0.15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.short}
|
||||||
</button>
|
<span className="hidden sm:inline">{` (${opt.example})`}</span>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Route Calculation */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{[
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
].map(opt => (
|
|
||||||
<button
|
|
||||||
key={String(opt.value)}
|
|
||||||
onClick={async () => {
|
|
||||||
try { await updateSetting('route_calculation', opt.value) }
|
|
||||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
|
||||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
|
||||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
transition: 'all 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface OAuthClient {
|
|||||||
client_id: string
|
client_id: string
|
||||||
redirect_uris: string[]
|
redirect_uris: string[]
|
||||||
allowed_scopes: string[]
|
allowed_scopes: string[]
|
||||||
|
allows_client_credentials: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
client_secret?: string // only present on create
|
client_secret?: string // only present on create
|
||||||
}
|
}
|
||||||
@@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
const [oauthRotating, setOauthRotating] = useState(false)
|
const [oauthRotating, setOauthRotating] = useState(false)
|
||||||
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
// oauthScopesOpen is managed internally by ScopeGroupPicker
|
||||||
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
const [oauthScopesExpanded, setOauthScopesExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
const [oauthIsMachine, setOauthIsMachine] = useState(false)
|
||||||
|
|
||||||
// MCP sub-tab state
|
// MCP sub-tab state
|
||||||
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth')
|
||||||
@@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
}, [mcpEnabled])
|
}, [mcpEnabled])
|
||||||
|
|
||||||
const handleCreateOAuthClient = async () => {
|
const handleCreateOAuthClient = async () => {
|
||||||
if (!oauthNewName.trim() || !oauthNewUris.trim()) return
|
if (!oauthNewName.trim()) return
|
||||||
|
if (!oauthIsMachine && !oauthNewUris.trim()) return
|
||||||
setOauthCreating(true)
|
setOauthCreating(true)
|
||||||
try {
|
try {
|
||||||
const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
|
||||||
const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
|
const d = await oauthApi.clients.create({
|
||||||
|
name: oauthNewName.trim(),
|
||||||
|
redirect_uris: uris,
|
||||||
|
allowed_scopes: oauthNewScopes,
|
||||||
|
...(oauthIsMachine ? { allows_client_credentials: true } : {}),
|
||||||
|
})
|
||||||
setOauthCreatedClient(d.client)
|
setOauthCreatedClient(d.client)
|
||||||
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }])
|
||||||
setOauthNewName('')
|
setOauthNewName('')
|
||||||
setOauthNewUris('')
|
setOauthNewUris('')
|
||||||
setOauthNewScopes([])
|
setOauthNewScopes([])
|
||||||
|
setOauthIsMachine(false)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('settings.oauth.toast.createError'))
|
toast.error(t('settings.oauth.toast.createError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.clientsHint')}</p>
|
||||||
|
|
||||||
<div className="flex justify-end mb-2">
|
<div className="flex justify-end mb-2">
|
||||||
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
|
<button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]); setOauthIsMachine(false) }}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors bg-slate-900 text-white hover:bg-slate-700">
|
||||||
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
<Plus className="w-3.5 h-3.5" /> {t('settings.oauth.createClient')}
|
||||||
</button>
|
</button>
|
||||||
@@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
<KeyRound className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
|
||||||
|
{client.allows_client_credentials && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] font-medium flex-shrink-0"
|
||||||
|
style={{ background: 'rgba(99,102,241,0.12)', color: '#4f46e5', border: '1px solid rgba(99,102,241,0.3)' }}>
|
||||||
|
{t('settings.oauth.badge.machine')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{t('settings.oauth.clientId')}: {client.client_id}
|
{t('settings.oauth.clientId')}: {client.client_id}
|
||||||
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
<span className="ml-3 font-sans">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||||
@@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
autoFocus />
|
autoFocus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||||
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)}
|
||||||
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||||
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
<div>
|
||||||
rows={3}
|
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span>
|
||||||
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p>
|
||||||
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
</div>
|
||||||
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
</label>
|
||||||
</div>
|
|
||||||
|
{!oauthIsMachine && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
|
||||||
|
<textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
|
||||||
|
placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2.5 border rounded-lg text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
|
||||||
|
<p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.scopes')}</label>
|
||||||
@@ -638,7 +666,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleCreateOAuthClient}
|
<button onClick={handleCreateOAuthClient}
|
||||||
disabled={!oauthNewName.trim() || !oauthNewUris.trim() || oauthCreating}
|
disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700 disabled:opacity-50">
|
||||||
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
{oauthCreating ? t('settings.oauth.modal.creating') : t('settings.oauth.modal.create')}
|
||||||
</button>
|
</button>
|
||||||
@@ -681,6 +709,12 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{oauthCreatedClient?.allows_client_credentials && (
|
||||||
|
<div className="p-3 rounded-lg border text-xs font-mono" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-primary)', color: 'var(--text-tertiary)' }}>
|
||||||
|
{t('settings.oauth.modal.machineClientUsage')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
<button onClick={() => { setOauthCreateOpen(false); setOauthCreatedClient(null) }}
|
||||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-slate-900 hover:bg-slate-700">
|
||||||
|
|||||||
@@ -240,14 +240,18 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
|
||||||
{t('settings.mapExperimental')}
|
|
||||||
</span>
|
|
||||||
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
|
<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 className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapboxSubtitle')}</div>
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-2">
|
<p className="text-xs text-slate-400 mt-2">
|
||||||
|
|||||||
@@ -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',
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -42,9 +42,11 @@ interface WeatherWidgetProps {
|
|||||||
lng: number | null
|
lng: number | null
|
||||||
date: string
|
date: string
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
/** Vertical icon-over-temp layout that inherits its color (for the day badge). */
|
||||||
|
stacked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) {
|
export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) {
|
||||||
const [weather, setWeather] = useState(null)
|
const [weather, setWeather] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
@@ -111,6 +113,15 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
|||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
const isClimate = weather.type === 'climate'
|
const isClimate = weather.type === 'climate'
|
||||||
|
|
||||||
|
if (stacked) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 9.5, fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
|
||||||
|
<WeatherIcon main={weather.main} size={13} />
|
||||||
|
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function ConfirmDialog({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter"
|
||||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingBottom: 'var(--bottom-nav-h)' }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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,6 +105,13 @@ export default function CustomSelect({
|
|||||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
{selected ? selected.label : placeholder}
|
{selected ? selected.label : placeholder}
|
||||||
</span>
|
</span>
|
||||||
|
{selected?.badge && (
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-muted)',
|
||||||
|
background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 999,
|
||||||
|
letterSpacing: '0.01em',
|
||||||
|
}}>{selected.badge}</span>
|
||||||
|
)}
|
||||||
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
|
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 200ms cubic-bezier(0.23,1,0.32,1)', transform: open ? 'rotate(180deg)' : 'none' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -186,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,14 +61,15 @@ export default function Modal({
|
|||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
trek-modal-enter
|
trek-modal-enter
|
||||||
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||||
flex flex-col max-h-[calc(100vh-180px)] md:max-h-[calc(100vh-90px)]
|
flex flex-col
|
||||||
|
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||||
`}
|
`}
|
||||||
style={{ background: 'var(--bg-card)' }}
|
style={{ background: 'var(--bg-card)' }}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* 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
|
||||||
@@ -80,14 +81,14 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface PlaceAvatarProps {
|
|||||||
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
|
const imageUrlFailed = useRef(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||||
|
|
||||||
@@ -86,7 +87,18 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P
|
|||||||
alt={place.name}
|
alt={place.name}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
onError={() => setPhotoSrc(null)}
|
onError={() => {
|
||||||
|
if (!imageUrlFailed.current && photoSrc === place.image_url && (place.google_place_id || place.osm_id)) {
|
||||||
|
imageUrlFailed.current = true
|
||||||
|
const photoId = place.google_place_id || place.osm_id!
|
||||||
|
const cacheKey = `refetch:${photoId}`
|
||||||
|
fetchPhoto(cacheKey, photoId, place.lat ?? undefined, place.lng ?? undefined, place.name,
|
||||||
|
entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setPhotoSrc(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { calculateSegments } from '../components/Map/RouteCalculator'
|
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult } from '../types'
|
import type { RouteSegment, RouteResult } from '../types'
|
||||||
|
|
||||||
@@ -9,20 +8,20 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||||
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||||
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
|
||||||
*/
|
*/
|
||||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
|
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
|
||||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
|
||||||
const routeAbortRef = useRef<AbortController | null>(null)
|
const routeAbortRef = useRef<AbortController | null>(null)
|
||||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||||
|
|
||||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
// Route is manual: only compute when explicitly enabled (the "show route" toggle).
|
||||||
|
if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); return }
|
||||||
// Read directly from store (not a render-phase ref) so callers after optimistic
|
// Read directly from store (not a render-phase ref) so callers after optimistic
|
||||||
// updates or non-optimistic deletes always see the latest assignments.
|
// updates or non-optimistic deletes always see the latest assignments.
|
||||||
const currentAssignments = useTripStore.getState().assignments || {}
|
const currentAssignments = useTripStore.getState().assignments || {}
|
||||||
@@ -67,35 +66,51 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
})),
|
})),
|
||||||
].sort((a, b) => a.pos - b.pos)
|
].sort((a, b) => a.pos - b.pos)
|
||||||
|
|
||||||
const segments: [number, number][][] = []
|
// Group consecutive located places into runs, resetting whenever a transport
|
||||||
let currentSeg: [number, number][] = []
|
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
|
||||||
|
const runs: { lat: number; lng: number }[][] = []
|
||||||
|
let currentRun: { lat: number; lng: number }[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.kind === 'place') {
|
if (entry.kind === 'place') {
|
||||||
currentSeg.push([entry.lat, entry.lng])
|
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||||
} else {
|
} else {
|
||||||
if (currentSeg.length >= 2) segments.push([...currentSeg])
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
currentSeg = []
|
currentRun = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentSeg.length >= 2) segments.push(currentSeg)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
|
|
||||||
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
|
const straightLines = (): [number, number][][] =>
|
||||||
|
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
|
||||||
|
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||||
|
|
||||||
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||||
|
// OSRM road geometry.
|
||||||
|
setRoute(straightLines())
|
||||||
|
|
||||||
if (segments.length === 0 && geocodedWaypoints.length < 2) {
|
|
||||||
setRoute(null); setRouteSegments([]); return
|
|
||||||
}
|
|
||||||
setRoute(segments.length > 0 ? segments : null)
|
|
||||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
routeAbortRef.current = controller
|
routeAbortRef.current = controller
|
||||||
try {
|
try {
|
||||||
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
|
const polylines: [number, number][][] = []
|
||||||
if (!controller.signal.aborted) setRouteSegments(calcSegments)
|
const allLegs: RouteSegment[] = []
|
||||||
|
for (const run of runs) {
|
||||||
|
try {
|
||||||
|
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
|
||||||
|
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
allLegs.push(...r.legs)
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') throw err
|
||||||
|
// OSRM failed for this run — fall back to a straight line, no times.
|
||||||
|
polylines.push(run.map(p => [p.lat, p.lng] as [number, number]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs) }
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
|
||||||
else if (!(err instanceof Error)) setRouteSegments([])
|
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
|
||||||
}
|
}
|
||||||
}, [routeCalcEnabled])
|
}, [enabled, profile])
|
||||||
|
|
||||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||||
@@ -117,7 +132,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||||
updateRouteForDay(selectedDayId)
|
updateRouteForDay(selectedDayId)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedDayId, selectedDayAssignments, transportSignature])
|
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
|
||||||
|
|
||||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,48 @@
|
|||||||
import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'
|
import React, { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import de from './translations/de'
|
import en from '@trek/shared/i18n/en'
|
||||||
import en from './translations/en'
|
import type { SupportedLanguageCode } from '@trek/shared'
|
||||||
import es from './translations/es'
|
import {
|
||||||
import fr from './translations/fr'
|
SUPPORTED_LANGUAGES,
|
||||||
import hu from './translations/hu'
|
getLocaleForLanguage,
|
||||||
import it from './translations/it'
|
getIntlLanguage,
|
||||||
import ru from './translations/ru'
|
isRtlLanguage,
|
||||||
import zh from './translations/zh'
|
} from '@trek/shared'
|
||||||
import zhTw from './translations/zhTw'
|
import type { TranslationStrings } from '@trek/shared/i18n'
|
||||||
import nl from './translations/nl'
|
|
||||||
import id from './translations/id'
|
|
||||||
import ar from './translations/ar'
|
|
||||||
import br from './translations/br'
|
|
||||||
import cs from './translations/cs'
|
|
||||||
import pl from './translations/pl'
|
|
||||||
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
|
||||||
|
|
||||||
export { SUPPORTED_LANGUAGES }
|
export { SUPPORTED_LANGUAGES }
|
||||||
|
|
||||||
type TranslationStrings = Record<string, string | { name: string; category: string }[]>
|
// One explicit dynamic import per locale — Vite code-splits a separate chunk per locale.
|
||||||
|
// Only the active locale is fetched; en is always available synchronously as the fallback.
|
||||||
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
const localeLoaders: Record<SupportedLanguageCode, () => Promise<{ default: TranslationStrings }>> = {
|
||||||
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
en: () => Promise.resolve({ default: en }),
|
||||||
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
|
de: () => import('@trek/shared/i18n/de'),
|
||||||
|
es: () => import('@trek/shared/i18n/es'),
|
||||||
|
fr: () => import('@trek/shared/i18n/fr'),
|
||||||
|
hu: () => import('@trek/shared/i18n/hu'),
|
||||||
|
it: () => import('@trek/shared/i18n/it'),
|
||||||
|
tr: () => import('@trek/shared/i18n/tr'),
|
||||||
|
ru: () => import('@trek/shared/i18n/ru'),
|
||||||
|
zh: () => import('@trek/shared/i18n/zh'),
|
||||||
|
'zh-TW': () => import('@trek/shared/i18n/zh-TW'),
|
||||||
|
nl: () => import('@trek/shared/i18n/nl'),
|
||||||
|
id: () => import('@trek/shared/i18n/id'),
|
||||||
|
ar: () => import('@trek/shared/i18n/ar'),
|
||||||
|
br: () => import('@trek/shared/i18n/br'),
|
||||||
|
cs: () => import('@trek/shared/i18n/cs'),
|
||||||
|
pl: () => import('@trek/shared/i18n/pl'),
|
||||||
|
ja: () => import('@trek/shared/i18n/ja'),
|
||||||
|
ko: () => import('@trek/shared/i18n/ko'),
|
||||||
|
uk: () => import('@trek/shared/i18n/uk'),
|
||||||
|
gr: () => import('@trek/shared/i18n/gr'),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
// Re-export pure helpers that live in shared so downstream consumers can import them
|
||||||
const LOCALES: Record<string, string> = Object.fromEntries(
|
// through this module without changing their import path.
|
||||||
SUPPORTED_LANGUAGES.map(l => [l.value, l.locale])
|
export { getLocaleForLanguage, getIntlLanguage, isRtlLanguage }
|
||||||
)
|
|
||||||
const RTL_LANGUAGES = new Set(['ar'])
|
|
||||||
|
|
||||||
export function getLocaleForLanguage(language: string): string {
|
// Detects the user's preferred language from browser/OS settings.
|
||||||
return LOCALES[language] || LOCALES.en
|
// Returns null if no supported language matches.
|
||||||
}
|
|
||||||
|
|
||||||
export function getIntlLanguage(language: string): string {
|
|
||||||
if (language === 'br') return 'pt-BR'
|
|
||||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRtlLanguage(language: string): boolean {
|
|
||||||
return RTL_LANGUAGES.has(language)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detects the user's preferred language from the browser/OS settings and maps
|
|
||||||
// it to one of the supported language codes. Returns null if no match is found.
|
|
||||||
export function detectBrowserLanguage(): string | null {
|
export function detectBrowserLanguage(): string | null {
|
||||||
if (typeof navigator === 'undefined') return null
|
if (typeof navigator === 'undefined') return null
|
||||||
const browserLangs = navigator.languages?.length
|
const browserLangs = navigator.languages?.length
|
||||||
@@ -55,17 +51,14 @@ export function detectBrowserLanguage(): string | null {
|
|||||||
const supported = SUPPORTED_LANGUAGES.map(l => l.value)
|
const supported = SUPPORTED_LANGUAGES.map(l => l.value)
|
||||||
|
|
||||||
for (const lang of browserLangs) {
|
for (const lang of browserLangs) {
|
||||||
// Exact match (e.g. 'de', 'zh-TW') — case-insensitive
|
|
||||||
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
|
const exactMatch = supported.find(s => s.toLowerCase() === lang.toLowerCase())
|
||||||
if (exactMatch) return exactMatch
|
if (exactMatch) return exactMatch
|
||||||
|
|
||||||
// pt-BR has no exact match (our code is 'br', not 'pt-BR'), so map it explicitly.
|
// pt-BR has no exact match (our code is 'br'), so map it explicitly.
|
||||||
// pt-PT and bare 'pt' are NOT mapped — they fall through to null and let the
|
// pt-PT and bare 'pt' are NOT mapped — they fall through to null.
|
||||||
// server default or 'en' fallback apply instead.
|
|
||||||
if (lang.toLowerCase() === 'pt-br') return 'br'
|
if (lang.toLowerCase() === 'pt-br') return 'br'
|
||||||
|
|
||||||
// Prefix match (e.g. 'de-AT' → 'de', 'zh-CN' → 'zh') — case-insensitive
|
const prefix = lang.split('-')[0]?.toLowerCase()
|
||||||
const prefix = lang.split('-')[0].toLowerCase()
|
|
||||||
const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
|
const prefixMatch = supported.find(s => s.toLowerCase() === prefix)
|
||||||
if (prefixMatch) return prefixMatch
|
if (prefixMatch) return prefixMatch
|
||||||
}
|
}
|
||||||
@@ -87,18 +80,27 @@ interface TranslationProviderProps {
|
|||||||
|
|
||||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||||
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
const language = useSettingsStore((s) => s.settings.language) || 'en'
|
||||||
|
const [strings, setStrings] = useState<TranslationStrings>(en)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.lang = language
|
document.documentElement.lang = language
|
||||||
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
document.documentElement.dir = isRtlLanguage(language) ? 'rtl' : 'ltr'
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
const value = useMemo((): TranslationContextValue => {
|
useEffect(() => {
|
||||||
const strings = translations[language] || translations.en
|
const loader = localeLoaders[language as SupportedLanguageCode]
|
||||||
const fallback = translations.en
|
if (!loader) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
loader().then(mod => {
|
||||||
|
if (!cancelled) setStrings(mod.default)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [language])
|
||||||
|
|
||||||
|
const value = useMemo((): TranslationContextValue => {
|
||||||
function t(key: string, params?: Record<string, string | number>): string {
|
function t(key: string, params?: Record<string, string | number>): string {
|
||||||
let val: string = (strings[key] ?? fallback[key] ?? key) as string
|
let val: string = (strings[key] ?? en[key] ?? key) as string
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v))
|
||||||
@@ -108,7 +110,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { t, language, locale: getLocaleForLanguage(language) }
|
return { t, language, locale: getLocaleForLanguage(language) }
|
||||||
}, [language])
|
}, [strings, language])
|
||||||
|
|
||||||
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,4 @@
|
|||||||
export const SUPPORTED_LANGUAGES = [
|
// Canonical language registry now lives in @trek/shared. Re-exported here so
|
||||||
{ value: 'de', label: 'Deutsch', locale: 'de-DE' },
|
// existing imports of './supportedLanguages' continue to work unchanged.
|
||||||
{ value: 'en', label: 'English', locale: 'en-US' },
|
export { SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_CODES } from '@trek/shared'
|
||||||
{ value: 'es', label: 'Español', locale: 'es-ES' },
|
export type { SupportedLanguageCode } from '@trek/shared'
|
||||||
{ value: 'fr', label: 'Français', locale: 'fr-FR' },
|
|
||||||
{ value: 'hu', label: 'Magyar', locale: 'hu-HU' },
|
|
||||||
{ value: 'nl', label: 'Nederlands', locale: 'nl-NL' },
|
|
||||||
{ value: 'br', label: 'Português (Brasil)', locale: 'pt-BR' },
|
|
||||||
{ value: 'cs', label: 'Česky', locale: 'cs-CZ' },
|
|
||||||
{ value: 'pl', label: 'Polski', locale: 'pl-PL' },
|
|
||||||
{ value: 'ru', label: 'Русский', locale: 'ru-RU' },
|
|
||||||
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
|
||||||
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
|
||||||
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
|
||||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
|
||||||
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGE_CODES: string[] = SUPPORTED_LANGUAGES.map(l => l.value)
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+19
-1
@@ -807,8 +807,26 @@ img[alt="TREK"] {
|
|||||||
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
|
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
|
||||||
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
|
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
|
||||||
.collab-note-md-full pre code { padding: 0; background: none; }
|
.collab-note-md-full pre code { padding: 0; background: none; }
|
||||||
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; }
|
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; word-break: break-all; }
|
||||||
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
|
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
|
||||||
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
|
||||||
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
|
||||||
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
|
.collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; }
|
||||||
|
|
||||||
|
/* Day-plan header action grid (edit / +transport / note / collapse) */
|
||||||
|
.dp-day-actions button {
|
||||||
|
color: var(--text-faint);
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
.dp-day-actions button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
/* Reveal the action grid only when hovering the day row (pointer devices).
|
||||||
|
Touch devices (hover: none) keep it visible; the selected day stays visible too. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.dp-day-actions { opacity: 0; transition: opacity 0.12s ease; }
|
||||||
|
.dp-day-header:hover .dp-day-actions,
|
||||||
|
.dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import ReactDOM from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
|
||||||
|
startConnectivityProbe()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -1240,6 +1240,15 @@ interface SidebarContentProps {
|
|||||||
|
|
||||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
|
||||||
const { language } = useTranslation()
|
const { language } = useTranslation()
|
||||||
|
const statsContentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = statsContentRef.current
|
||||||
|
if (!el || typeof ResizeObserver === 'undefined') return
|
||||||
|
const ro = new ResizeObserver(() => setStatsWidth(el.offsetWidth))
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}, [])
|
||||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||||
const tm = dark ? '#94a3b8' : '#64748b'
|
const tm = dark ? '#94a3b8' : '#64748b'
|
||||||
@@ -1290,7 +1299,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
// Bucket list content
|
// Bucket list content
|
||||||
const bucketContent = (
|
const bucketContent = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px' }}>
|
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
|
||||||
{bucketList.map(item => (
|
{bucketList.map(item => (
|
||||||
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
|
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1400,7 +1409,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
|
|||||||
{/* Both tabs always rendered so the wider one sets the panel width */}
|
{/* Both tabs always rendered so the wider one sets the panel width */}
|
||||||
<div style={{ display: 'grid' }}>
|
<div style={{ display: 'grid' }}>
|
||||||
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
|
||||||
<div className="flex items-stretch justify-center">
|
<div ref={statsContentRef} className="flex items-stretch justify-center">
|
||||||
|
|
||||||
{/* ═══ SECTION 1: Numbers ═══ */}
|
{/* ═══ SECTION 1: Numbers ═══ */}
|
||||||
{/* Countries hero */}
|
{/* Countries hero */}
|
||||||
|
|||||||
@@ -401,6 +401,10 @@ describe('DashboardPage', () => {
|
|||||||
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
const copyButtons = screen.getAllByRole('button', { name: /copy/i });
|
||||||
await user.click(copyButtons[0]);
|
await user.click(copyButtons[0]);
|
||||||
|
|
||||||
|
// Confirm the copy dialog
|
||||||
|
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
|
||||||
|
await user.click(confirmButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Paris Adventure (Copy)')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -766,6 +770,10 @@ describe('DashboardPage', () => {
|
|||||||
expect(copyButtons.length).toBeGreaterThan(0);
|
expect(copyButtons.length).toBeGreaterThan(0);
|
||||||
await user.click(copyButtons[0]);
|
await user.click(copyButtons[0]);
|
||||||
|
|
||||||
|
// Confirm the copy dialog
|
||||||
|
const confirmButton = await screen.findByRole('button', { name: /copy trip/i });
|
||||||
|
await user.click(confirmButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Paris Adventure (Copy)').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -849,7 +857,6 @@ describe('DashboardPage', () => {
|
|||||||
temperature_unit: 'fahrenheit',
|
temperature_unit: 'fahrenheit',
|
||||||
time_format: '12h',
|
time_format: '12h',
|
||||||
show_place_description: false,
|
show_place_description: false,
|
||||||
route_calculation: false,
|
|
||||||
blur_booking_codes: false,
|
blur_booking_codes: false,
|
||||||
dashboard_currency: 'on',
|
dashboard_currency: 'on',
|
||||||
dashboard_timezone: 'on',
|
dashboard_timezone: 'on',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
|||||||
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
import TimezoneWidget from '../components/Dashboard/TimezoneWidget'
|
||||||
import TripFormModal from '../components/Trips/TripFormModal'
|
import TripFormModal from '../components/Trips/TripFormModal'
|
||||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||||
|
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { useCountUp } from '../hooks/useCountUp'
|
import { useCountUp } from '../hooks/useCountUp'
|
||||||
import {
|
import {
|
||||||
@@ -699,6 +700,7 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile' | 'mobile-currency' | 'mobile-timezone'>(false)
|
||||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => (localStorage.getItem('trek_dashboard_view') as 'grid' | 'list') || 'grid')
|
||||||
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => {
|
setViewMode(prev => {
|
||||||
@@ -815,14 +817,18 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
setArchivedTrips(prev => prev.map(update))
|
setArchivedTrips(prev => prev.map(update))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = async (trip: DashboardTrip) => {
|
const handleCopy = (trip: DashboardTrip) => setCopyTrip(trip)
|
||||||
|
|
||||||
|
const confirmCopy = async () => {
|
||||||
|
if (!copyTrip) return
|
||||||
try {
|
try {
|
||||||
const data = await tripsApi.copy(trip.id, { title: `${trip.title} (${t('dashboard.copySuffix')})` })
|
const data = await tripsApi.copy(copyTrip.id, { title: `${copyTrip.title} (${t('dashboard.copySuffix')})` })
|
||||||
setTrips(prev => sortTrips([data.trip, ...prev]))
|
setTrips(prev => sortTrips([data.trip, ...prev]))
|
||||||
toast.success(t('dashboard.toast.copied'))
|
toast.success(t('dashboard.toast.copied'))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('dashboard.toast.copyError'))
|
toast.error(t('dashboard.toast.copyError'))
|
||||||
}
|
}
|
||||||
|
setCopyTrip(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
@@ -1205,6 +1211,13 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
message={t('dashboard.confirm.delete', { title: deleteTrip?.title || '' })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CopyTripDialog
|
||||||
|
isOpen={!!copyTrip}
|
||||||
|
tripTitle={copyTrip?.title || ''}
|
||||||
|
onClose={() => setCopyTrip(null)}
|
||||||
|
onConfirm={confirmCopy}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1 }
|
0%, 100% { opacity: 1 }
|
||||||
|
|||||||
@@ -177,6 +177,24 @@ const mockJourneyDetail = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [
|
||||||
|
{
|
||||||
|
id: 100,
|
||||||
|
journey_id: 1,
|
||||||
|
photo_id: 100,
|
||||||
|
provider: 'local',
|
||||||
|
file_path: 'photos/test.jpg',
|
||||||
|
asset_id: null,
|
||||||
|
owner_id: null,
|
||||||
|
thumbnail_path: null,
|
||||||
|
caption: 'Colosseum',
|
||||||
|
sort_order: 0,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
shared: 1,
|
||||||
|
created_at: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
// ── MSW Handlers ─────────────────────────────────────────────────────────────
|
||||||
@@ -1468,7 +1486,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-074 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-074: Delete share link removes it', () => {
|
||||||
it('clicking "Remove share link" calls DELETE and returns to create state', async () => {
|
it('clicking "Delete link" calls DELETE and returns to create state', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
|
|
||||||
@@ -1493,10 +1511,10 @@ describe('JourneyDetailPage', () => {
|
|||||||
await openSettingsDialog(user);
|
await openSettingsDialog(user);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByText('Remove share link'));
|
await user.click(screen.getByText('Delete link'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(deleteCalled).toBe(true);
|
expect(deleteCalled).toBe(true);
|
||||||
@@ -1724,13 +1742,14 @@ describe('JourneyDetailPage', () => {
|
|||||||
it('renders the empty gallery state when journey has no photos', async () => {
|
it('renders the empty gallery state when journey has no photos', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
// Override with entries that have no photos
|
// Override with entries that have no photos and empty gallery
|
||||||
const emptyEntry = {
|
const emptyEntry = {
|
||||||
...mockJourneyDetail.entries[0],
|
...mockJourneyDetail.entries[0],
|
||||||
photos: [],
|
photos: [],
|
||||||
};
|
};
|
||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [emptyEntry],
|
entries: [emptyEntry],
|
||||||
|
gallery: [],
|
||||||
stats: { entries: 1, photos: 0, places: 1 },
|
stats: { entries: 1, photos: 0, places: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1981,10 +2000,9 @@ describe('JourneyDetailPage', () => {
|
|||||||
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The entry date '2026-03-15' is shown as a formatted overlay on each gallery photo
|
// Gallery photos render in a grid; each photo has a group container
|
||||||
// The component uses toLocaleDateString which produces "Mar 15, 2026" in en-US
|
const photos = document.querySelectorAll('[class*="aspect-square"]');
|
||||||
const dateOverlay = document.querySelector('[class*="opacity-0"]');
|
expect(photos.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(dateOverlay).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2022,6 +2040,11 @@ describe('JourneyDetailPage', () => {
|
|||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
entries: [immichEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [{
|
||||||
|
id: 200, journey_id: 1, photo_id: 200, provider: 'immich', file_path: null,
|
||||||
|
asset_id: 'asset-123', owner_id: 1, thumbnail_path: null,
|
||||||
|
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2056,6 +2079,11 @@ describe('JourneyDetailPage', () => {
|
|||||||
setupDefaultHandlers({
|
setupDefaultHandlers({
|
||||||
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
|
gallery: [{
|
||||||
|
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
|
||||||
|
asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
|
||||||
|
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
|
||||||
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<JourneyDetailPage />);
|
render(<JourneyDetailPage />);
|
||||||
@@ -2905,7 +2933,7 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
// The permission toggles show Timeline, Gallery, Map labels within the share section
|
||||||
// These reuse the same i18n keys as the main tab bar
|
// These reuse the same i18n keys as the main tab bar
|
||||||
expect(screen.getByText('Remove share link')).toBeInTheDocument();
|
expect(screen.getByText('Delete link')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Copy')).toBeInTheDocument();
|
expect(screen.getByText('Copy')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3265,25 +3293,14 @@ describe('JourneyDetailPage', () => {
|
|||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-141 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-141: Gallery upload triggers file input and calls API', () => {
|
||||||
it('uploading files in gallery creates an entry and uploads photos', async () => {
|
it('uploading files in gallery calls gallery upload API', async () => {
|
||||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
let createCalled = false;
|
|
||||||
let uploadCalled = false;
|
let uploadCalled = false;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/journeys/1/entries', () => {
|
http.post('/api/journeys/1/gallery/photos', () => {
|
||||||
createCalled = true;
|
|
||||||
return HttpResponse.json({
|
|
||||||
id: 99, journey_id: 1, author_id: 1, type: 'entry',
|
|
||||||
entry_date: '2026-04-11', title: 'Gallery', story: null, location_name: null,
|
|
||||||
location_lat: null, location_lng: null, mood: null, weather: null,
|
|
||||||
tags: [], pros_cons: null, visibility: 'private', sort_order: 0,
|
|
||||||
entry_time: null, photos: [], created_at: now, updated_at: now,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.post('/api/journeys/entries/99/photos', () => {
|
|
||||||
uploadCalled = true;
|
uploadCalled = true;
|
||||||
return HttpResponse.json([]);
|
return HttpResponse.json({ photos: [] });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3304,9 +3321,6 @@ describe('JourneyDetailPage', () => {
|
|||||||
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
|
const testFile = new File(['fake-content'], 'test-photo.jpg', { type: 'image/jpeg' });
|
||||||
await user.upload(fileInput, testFile);
|
await user.upload(fileInput, testFile);
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(createCalled).toBe(true);
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(uploadCalled).toBe(true);
|
expect(uploadCalled).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -3320,9 +3334,9 @@ describe('JourneyDetailPage', () => {
|
|||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.delete('/api/journeys/photos/100', () => {
|
http.delete('/api/journeys/1/gallery/100', () => {
|
||||||
deleteCalled = true;
|
deleteCalled = true;
|
||||||
return HttpResponse.json({ success: true });
|
return new HttpResponse(null, { status: 204 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { formatLocationName } from '../utils/formatters'
|
||||||
|
import { normalizeImageFiles } from '../utils/convertHeic'
|
||||||
|
import { type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useJourneyStore } from '../store/journeyStore'
|
import { useJourneyStore } from '../store/journeyStore'
|
||||||
@@ -8,6 +11,7 @@ import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
|
|||||||
import { addListener, removeListener } from '../api/websocket'
|
import { addListener, removeListener } from '../api/websocket'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
||||||
|
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
||||||
import JournalBody from '../components/Journey/JournalBody'
|
import JournalBody from '../components/Journey/JournalBody'
|
||||||
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
||||||
@@ -25,8 +29,9 @@ import {
|
|||||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
import type { JourneyEntry, JourneyPhoto, JourneyDetail } from '../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
||||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||||
|
import { getApiErrorMessage } from '../types'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
'linear-gradient(135deg, #0F172A 0%, #6366F1 45%, #EC4899 100%)',
|
||||||
@@ -67,16 +72,18 @@ function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d: string): { weekday: string; month: string; day: number } {
|
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||||
const date = new Date(d + 'T00:00:00')
|
const date = new Date(d + 'T00:00:00')
|
||||||
|
// Pass the app's selected locale so weekday/month follow the UI language
|
||||||
|
// instead of the browser's navigator.language.
|
||||||
return {
|
return {
|
||||||
weekday: date.toLocaleDateString(undefined, { weekday: 'long' }),
|
weekday: date.toLocaleDateString(locale, { weekday: 'long' }),
|
||||||
month: date.toLocaleDateString(undefined, { month: 'long' }),
|
month: date.toLocaleDateString(locale, { month: 'long' }),
|
||||||
day: date.getDate(),
|
day: date.getDate(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function photoUrl(p: JourneyPhoto, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
function photoUrl(p: { photo_id: number }, size: 'thumbnail' | 'original' = 'thumbnail'): string {
|
||||||
return `/api/photos/${p.photo_id}/${size}`
|
return `/api/photos/${p.photo_id}/${size}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +91,7 @@ export default function JourneyDetailPage() {
|
|||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||||
const mapRef = useRef<JourneyMapHandle>(null)
|
const mapRef = useRef<JourneyMapHandle>(null)
|
||||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||||
@@ -186,7 +193,9 @@ export default function JourneyDetailPage() {
|
|||||||
const winner = lastPast || firstAhead
|
const winner = lastPast || firstAhead
|
||||||
if (winner) {
|
if (winner) {
|
||||||
setActiveEntryId(winner.id)
|
setActiveEntryId(winner.id)
|
||||||
mapRef.current?.highlightMarker(winner.id)
|
if (locatedEntryIdsRef.current.has(winner.id)) {
|
||||||
|
mapRef.current?.highlightMarker(winner.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
@@ -277,16 +286,38 @@ export default function JourneyDetailPage() {
|
|||||||
[current?.entries]
|
[current?.entries]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sidebarMapItems = useMemo(() => mapEntries.map(e => ({
|
const sidebarMapItems = useMemo(() => {
|
||||||
id: String(e.id),
|
const allDates = [...new Set(
|
||||||
lat: e.location_lat!,
|
(current?.entries || [])
|
||||||
lng: e.location_lng!,
|
.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||||
title: e.title || '',
|
.map(e => e.entry_date)
|
||||||
location_name: e.location_name || '',
|
.sort()
|
||||||
mood: e.mood,
|
)]
|
||||||
created_at: e.entry_date,
|
const sorted = [...mapEntries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
|
||||||
entry_date: e.entry_date,
|
const dayCounters = new Map<string, number>()
|
||||||
})), [mapEntries])
|
return sorted.map(e => {
|
||||||
|
const dayIdx = allDates.indexOf(e.entry_date)
|
||||||
|
const dayLabel = (dayCounters.get(e.entry_date) ?? 0) + 1
|
||||||
|
dayCounters.set(e.entry_date, dayLabel)
|
||||||
|
return {
|
||||||
|
id: String(e.id),
|
||||||
|
lat: e.location_lat!,
|
||||||
|
lng: e.location_lng!,
|
||||||
|
title: e.title || '',
|
||||||
|
location_name: e.location_name || '',
|
||||||
|
mood: e.mood,
|
||||||
|
created_at: e.entry_date,
|
||||||
|
entry_date: e.entry_date,
|
||||||
|
dayColor: DAY_COLORS[dayIdx % DAY_COLORS.length],
|
||||||
|
dayLabel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [mapEntries, current?.entries])
|
||||||
|
|
||||||
|
const locatedEntryIdsRef = useRef(new Set<string>())
|
||||||
|
useEffect(() => {
|
||||||
|
locatedEntryIdsRef.current = new Set(sidebarMapItems.map(m => m.id))
|
||||||
|
}, [sidebarMapItems])
|
||||||
|
|
||||||
const tripDates = useMemo(() => {
|
const tripDates = useMemo(() => {
|
||||||
const dates = new Set<string>()
|
const dates = new Set<string>()
|
||||||
@@ -313,7 +344,7 @@ export default function JourneyDetailPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineEntries = current.entries.filter(e => e.title !== 'Gallery' && e.title !== '[Trip Photos]' && (!hideSkeletons || e.type !== 'skeleton'))
|
const timelineEntries = current.entries.filter(e => (!hideSkeletons || e.type !== 'skeleton'))
|
||||||
const dayGroups = groupByDate(timelineEntries)
|
const dayGroups = groupByDate(timelineEntries)
|
||||||
const sortedDates = [...dayGroups.keys()].sort()
|
const sortedDates = [...dayGroups.keys()].sort()
|
||||||
|
|
||||||
@@ -422,7 +453,7 @@ export default function JourneyDetailPage() {
|
|||||||
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
||||||
: 'flex w-full overflow-hidden'
|
: 'flex w-full overflow-hidden'
|
||||||
}
|
}
|
||||||
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
|
style={!isMobile ? { height: 'calc(100dvh - var(--nav-h, 56px))' } : undefined}
|
||||||
>
|
>
|
||||||
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
||||||
<div
|
<div
|
||||||
@@ -430,7 +461,7 @@ export default function JourneyDetailPage() {
|
|||||||
className={
|
className={
|
||||||
isMobile
|
isMobile
|
||||||
? ''
|
? ''
|
||||||
: 'flex-1 overflow-y-auto journey-feed-scroll'
|
: 'flex-1 xl:max-w-[50%] overflow-y-auto journey-feed-scroll'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
||||||
@@ -482,7 +513,7 @@ export default function JourneyDetailPage() {
|
|||||||
>
|
>
|
||||||
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
{hideSkeletons ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
</button>
|
</button>
|
||||||
<span className="absolute top-full mt-2 left-1/2 -translate-x-1/2 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
|
<span className="absolute top-full mt-2 right-0 px-2 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 text-[11px] font-medium whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
|
||||||
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
{hideSkeletons ? t('journey.skeletons.show') : t('journey.skeletons.hide')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,14 +606,14 @@ export default function JourneyDetailPage() {
|
|||||||
|
|
||||||
{sortedDates.map((date, dayIdx) => {
|
{sortedDates.map((date, dayIdx) => {
|
||||||
const entries = dayGroups.get(date)!
|
const entries = dayGroups.get(date)!
|
||||||
const fd = formatDate(date)
|
const fd = formatDate(date, locale)
|
||||||
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
const locations = [...new Set(entries.map(e => e.location_name).filter(Boolean))]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
||||||
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-bold text-white" style={{ background: DAY_COLORS[dayIdx % DAY_COLORS.length] }}>
|
||||||
{dayIdx + 1}
|
{dayIdx + 1}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -611,7 +642,7 @@ export default function JourneyDetailPage() {
|
|||||||
.catch(() => toast.error(t('common.errorOccurred')))
|
.catch(() => toast.error(t('common.errorOccurred')))
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}>
|
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`} onMouseEnter={() => { setActiveEntryId(String(entry.id)); mapRef.current?.highlightMarker(String(entry.id)) }} style={String(entry.id) === activeEntryId ? { outline: `2px solid ${DAY_COLORS[dayIdx % DAY_COLORS.length]}`, outlineOffset: '3px', borderRadius: '12px' } : undefined}>
|
||||||
{canReorder && (
|
{canReorder && (
|
||||||
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
||||||
<button
|
<button
|
||||||
@@ -665,10 +696,11 @@ export default function JourneyDetailPage() {
|
|||||||
>
|
>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
entries={current.entries}
|
entries={current.entries}
|
||||||
|
gallery={current.gallery || []}
|
||||||
journeyId={current.id}
|
journeyId={current.id}
|
||||||
userId={useAuthStore.getState().user?.id || 0}
|
userId={useAuthStore.getState().user?.id || 0}
|
||||||
trips={current.trips}
|
trips={current.trips}
|
||||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption ?? null, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||||
onRefresh={() => loadJourney(Number(id))}
|
onRefresh={() => loadJourney(Number(id))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -705,7 +737,7 @@ export default function JourneyDetailPage() {
|
|||||||
entry={editingEntry}
|
entry={editingEntry}
|
||||||
journeyId={current.id}
|
journeyId={current.id}
|
||||||
tripDates={tripDates}
|
tripDates={tripDates}
|
||||||
galleryPhotos={current.entries.flatMap(e => e.photos || [])}
|
galleryPhotos={current.gallery || []}
|
||||||
onClose={() => setEditingEntry(null)}
|
onClose={() => setEditingEntry(null)}
|
||||||
onSave={async (data) => {
|
onSave={async (data) => {
|
||||||
let entryId = editingEntry.id
|
let entryId = editingEntry.id
|
||||||
@@ -717,8 +749,8 @@ export default function JourneyDetailPage() {
|
|||||||
}
|
}
|
||||||
return entryId
|
return entryId
|
||||||
}}
|
}}
|
||||||
onUploadPhotos={async (entryId, formData) => {
|
onUploadPhotos={async (entryId, files, cbs) => {
|
||||||
return await uploadPhotos(entryId, formData)
|
return await uploadPhotos(entryId, files, cbs)
|
||||||
}}
|
}}
|
||||||
onDone={() => {
|
onDone={() => {
|
||||||
setEditingEntry(null)
|
setEditingEntry(null)
|
||||||
@@ -733,7 +765,8 @@ export default function JourneyDetailPage() {
|
|||||||
journey={current}
|
journey={current}
|
||||||
onClose={() => setShowSettings(false)}
|
onClose={() => setShowSettings(false)}
|
||||||
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
onSaved={() => { setShowSettings(false); loadJourney(Number(id)) }}
|
||||||
onOpenInvite={() => { setShowSettings(false); setShowInvite(true) }}
|
onOpenInvite={() => { setShowInvite(true) }}
|
||||||
|
onRefresh={() => loadJourney(Number(id))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -816,7 +849,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
fullMapRef: React.RefObject<JourneyMapHandle | null>
|
||||||
onLocationClick: (id: string) => void
|
onLocationClick: (id: string) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
// group map entries by date
|
// group map entries by date
|
||||||
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
const byDate = new Map<string, { entry: JourneyEntry; globalIdx: number }[]>()
|
||||||
mapEntries.forEach((e, i) => {
|
mapEntries.forEach((e, i) => {
|
||||||
@@ -872,7 +905,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
<div className="px-5 pb-5">
|
<div className="px-5 pb-5">
|
||||||
{dates.map((date, dayIdx) => {
|
{dates.map((date, dayIdx) => {
|
||||||
const items = byDate.get(date)!
|
const items = byDate.get(date)!
|
||||||
const fd = formatDate(date)
|
const fd = formatDate(date, locale)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={date}>
|
<div key={date}>
|
||||||
@@ -915,7 +948,7 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
<span className="text-[14px] font-semibold text-zinc-900 dark:text-white truncate">{e.title || e.location_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 truncate">
|
<div className="text-[11px] text-zinc-500 truncate">
|
||||||
{e.location_name}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
{formatLocationName(e.location_name)}{e.entry_time ? ` · ${e.entry_time}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -942,19 +975,21 @@ function MapView({ entries, mapEntries, sortedDates, activeLocationId, fullMapRe
|
|||||||
|
|
||||||
// ── Gallery View ──────────────────────────────────────────────────────────
|
// ── Gallery View ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh }: {
|
||||||
entries: JourneyEntry[]
|
entries: JourneyEntry[]
|
||||||
|
gallery: GalleryPhoto[]
|
||||||
journeyId: number
|
journeyId: number
|
||||||
userId: number
|
userId: number
|
||||||
trips: JourneyTrip[]
|
trips: JourneyTrip[]
|
||||||
onPhotoClick: (photos: JourneyPhoto[], index: number) => void
|
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [showPicker, setShowPicker] = useState(false)
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||||
const [galleryUploading, setGalleryUploading] = useState(false)
|
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||||
|
const galleryUploading = galleryProgress !== null
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
// check which providers are enabled AND connected for the current user
|
// check which providers are enabled AND connected for the current user
|
||||||
@@ -980,12 +1015,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
})()
|
})()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const allPhotos: { photo: JourneyPhoto; entry: JourneyEntry }[] = []
|
const allPhotos = gallery
|
||||||
for (const e of entries) {
|
|
||||||
for (const p of e.photos) {
|
|
||||||
allPhotos.push({ photo: p, entry: e })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
|
||||||
|
|
||||||
@@ -999,52 +1029,47 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleGalleryUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files
|
const files = e.target.files
|
||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
setGalleryUploading(true)
|
setGalleryProgress({ done: 0, total: files.length })
|
||||||
try {
|
try {
|
||||||
// find existing "Gallery" entry or create one. The stored title is the
|
const normalized = await normalizeImageFiles(files)
|
||||||
// literal 'Gallery' (server-side checks look for this exact string) —
|
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||||
// do not send a translated label here.
|
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||||
let galleryEntry = entries.find(e => e.title === 'Gallery' && e.type === 'entry')
|
})
|
||||||
let entryId = galleryEntry?.id
|
if (failed.length > 0) {
|
||||||
if (!entryId) {
|
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||||
const entry = await journeyApi.createEntry(journeyId, {
|
} else {
|
||||||
title: 'Gallery',
|
toast.success(t('journey.photosUploaded', { count: String(files.length) }))
|
||||||
entry_date: new Date().toISOString().split('T')[0],
|
|
||||||
type: 'entry',
|
|
||||||
})
|
|
||||||
entryId = entry.id
|
|
||||||
}
|
}
|
||||||
const formData = new FormData()
|
|
||||||
for (const f of files) formData.append('photos', f)
|
|
||||||
await journeyApi.uploadPhotos(entryId, formData)
|
|
||||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
|
||||||
onRefresh()
|
onRefresh()
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error(t('journey.settings.coverFailed'))
|
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed')))
|
||||||
} finally {
|
} finally {
|
||||||
setGalleryUploading(false)
|
setGalleryProgress(null)
|
||||||
}
|
}
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeletePhoto = async (photoId: number) => {
|
const handleDeletePhoto = async (galleryPhotoId: number) => {
|
||||||
// Optimistic update — remove photo from local state immediately
|
|
||||||
const store = useJourneyStore.getState()
|
const store = useJourneyStore.getState()
|
||||||
if (store.current) {
|
if (!store.current) return
|
||||||
const updated = {
|
|
||||||
|
// Optimistic update — remove from gallery and all entry photo lists
|
||||||
|
useJourneyStore.setState({
|
||||||
|
current: {
|
||||||
...store.current,
|
...store.current,
|
||||||
|
gallery: (store.current.gallery || []).filter(p => p.id !== galleryPhotoId),
|
||||||
entries: store.current.entries.map(e => ({
|
entries: store.current.entries.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
photos: e.photos.filter(p => p.id !== photoId),
|
photos: e.photos.filter(p => p.id !== galleryPhotoId),
|
||||||
})).filter(e => e.type !== 'entry' || e.title !== 'Gallery' || e.photos.length > 0 || e.story),
|
})),
|
||||||
}
|
},
|
||||||
useJourneyStore.setState({ current: updated })
|
})
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await journeyApi.deletePhoto(photoId)
|
await journeyApi.deleteGalleryPhoto(journeyId, galleryPhotoId)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
onRefresh() // Revert on error
|
onRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1064,7 +1089,7 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[11px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{galleryUploading ? (
|
{galleryUploading ? (
|
||||||
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
<><div className="w-3 h-3 border-2 border-white/30 dark:border-zinc-900/30 border-t-white dark:border-t-zinc-900 rounded-full animate-spin" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : t('journey.editor.uploading')}</>
|
||||||
) : (
|
) : (
|
||||||
<><Plus size={12} /> {t('common.upload')}</>
|
<><Plus size={12} /> {t('common.upload')}</>
|
||||||
)}
|
)}
|
||||||
@@ -1092,11 +1117,11 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
|
||||||
{allPhotos.map(({ photo, entry }, i) => (
|
{allPhotos.map((photo, i) => (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||||
onClick={() => onPhotoClick(allPhotos.map(a => a.photo), i)}
|
onClick={() => onPhotoClick(allPhotos, i)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={photoUrl(photo, 'thumbnail')}
|
src={photoUrl(photo, 'thumbnail')}
|
||||||
@@ -1125,11 +1150,6 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
<p className="text-[10px] text-white truncate">{photo.caption}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-1.5 left-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<span className="text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-black/50 backdrop-blur text-white">
|
|
||||||
{new Date(entry.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1142,25 +1162,19 @@ function GalleryView({ entries, journeyId, userId, trips, onPhotoClick, onRefres
|
|||||||
userId={userId}
|
userId={userId}
|
||||||
entries={entriesWithContent}
|
entries={entriesWithContent}
|
||||||
trips={trips}
|
trips={trips}
|
||||||
existingAssetIds={new Set(entries.flatMap(e => (e.photos || []).filter(p => p.asset_id).map(p => p.asset_id!)))}
|
existingAssetIds={new Set(gallery.filter(p => p.asset_id).map(p => p.asset_id!))}
|
||||||
onClose={() => setShowPicker(false)}
|
onClose={() => setShowPicker(false)}
|
||||||
onAdd={async (groups, entryId) => {
|
onAdd={async (groups, entryId) => {
|
||||||
let targetId = entryId
|
|
||||||
if (!targetId) {
|
|
||||||
try {
|
|
||||||
const entry = await journeyApi.createEntry(journeyId, {
|
|
||||||
title: 'Gallery',
|
|
||||||
entry_date: new Date().toISOString().split('T')[0],
|
|
||||||
type: 'entry',
|
|
||||||
})
|
|
||||||
targetId = entry.id
|
|
||||||
} catch { return }
|
|
||||||
}
|
|
||||||
let added = 0
|
let added = 0
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
try {
|
try {
|
||||||
const result = await journeyApi.addProviderPhotos(targetId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
if (entryId) {
|
||||||
added += result.added || 0
|
const result = await journeyApi.addProviderPhotos(entryId, pickerProvider!, group.assetIds, undefined, group.passphrase)
|
||||||
|
added += result.added || 0
|
||||||
|
} else {
|
||||||
|
const result = await journeyApi.addProviderPhotosToGallery(journeyId, pickerProvider!, group.assetIds, group.passphrase)
|
||||||
|
added += result.added || 0
|
||||||
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
@@ -1358,7 +1372,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
|||||||
{entry.location_name && (
|
{entry.location_name && (
|
||||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide max-w-full overflow-hidden">
|
||||||
<MapPin size={10} className="flex-shrink-0" />
|
<MapPin size={10} className="flex-shrink-0" />
|
||||||
<span className="truncate">{entry.location_name}</span>
|
<span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -1401,7 +1415,7 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
|||||||
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
<div className="flex items-center gap-2 min-w-0 flex-1 mr-2">
|
||||||
{entry.location_name && (
|
{entry.location_name && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500 max-w-full overflow-hidden">
|
||||||
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{entry.location_name}</span>
|
<MapPin size={10} className="flex-shrink-0" /> <span className="truncate">{formatLocationName(entry.location_name)}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{entry.entry_time && (
|
{entry.entry_time && (
|
||||||
@@ -1480,7 +1494,7 @@ function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () =>
|
|||||||
{entry.title || t('journey.detail.newEntry')}
|
{entry.title || t('journey.detail.newEntry')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 mt-0.5">
|
<div className="text-[11px] text-zinc-500 mt-0.5">
|
||||||
{entry.location_name || ''}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
{formatLocationName(entry.location_name)}{entry.entry_time ? ` · ${entry.entry_time}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
<div className="text-[11px] text-zinc-500 font-medium flex-shrink-0">
|
||||||
@@ -1764,11 +1778,11 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
: t('journey.picker.newGallery')
|
: t('journey.picker.newGallery')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
<div className="fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||||
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
{provider === 'immich' ? 'Immich' : 'Synology Photos'}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -1778,7 +1792,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
{/* Filter bar */}
|
||||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1.5 mb-3">
|
<div className="flex gap-1.5 mb-3">
|
||||||
{[
|
{[
|
||||||
@@ -1864,7 +1878,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add-to entry selector */}
|
{/* Add-to entry selector */}
|
||||||
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
<div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||||
<button
|
<button
|
||||||
@@ -1917,7 +1931,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||||
if (selectable.length === 0) return null
|
if (selectable.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900">
|
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
@@ -1942,7 +1956,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Photo grid */}
|
{/* Photo grid */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||||
@@ -2015,7 +2029,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||||
@@ -2161,13 +2175,14 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
entry: JourneyEntry
|
entry: JourneyEntry
|
||||||
journeyId: number
|
journeyId: number
|
||||||
tripDates: Set<string>
|
tripDates: Set<string>
|
||||||
galleryPhotos: JourneyPhoto[]
|
galleryPhotos: GalleryPhoto[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: Record<string, unknown>) => Promise<number>
|
onSave: (data: Record<string, unknown>) => Promise<number>
|
||||||
onUploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||||
onDone: () => void
|
onDone: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const [title, setTitle] = useState(entry.title || '')
|
const [title, setTitle] = useState(entry.title || '')
|
||||||
const [story, setStory] = useState(entry.story || '')
|
const [story, setStory] = useState(entry.story || '')
|
||||||
@@ -2186,8 +2201,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null)
|
||||||
const [photos, setPhotos] = useState<JourneyPhoto[]>(entry.photos || [])
|
const [photos, setPhotos] = useState<(JourneyPhoto | GalleryPhoto)[]>(entry.photos || [])
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
const [pendingLinkIds, setPendingLinkIds] = useState<number[]>([])
|
||||||
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
const [showGalleryPick, setShowGalleryPick] = useState(false)
|
||||||
@@ -2214,6 +2229,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
pendingLinkIds.length > 0
|
pendingLinkIds.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id))
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||||
onClose()
|
onClose()
|
||||||
@@ -2237,9 +2254,21 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
})
|
})
|
||||||
// upload queued files after entry is created
|
// upload queued files after entry is created
|
||||||
if (pendingFiles.length > 0 && entryId) {
|
if (pendingFiles.length > 0 && entryId) {
|
||||||
const formData = new FormData()
|
const filesToUpload = pendingFiles
|
||||||
for (const f of pendingFiles) formData.append('photos', f)
|
setUploadProgress({ done: 0, total: filesToUpload.length })
|
||||||
await onUploadPhotos(entryId, formData)
|
try {
|
||||||
|
const { failed } = await onUploadPhotos(entryId, filesToUpload, {
|
||||||
|
onProgress: p => setUploadProgress({ done: p.done, total: p.total }),
|
||||||
|
})
|
||||||
|
setPendingFiles(failed)
|
||||||
|
if (failed.length > 0) {
|
||||||
|
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(filesToUpload.length) }))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('journey.editor.uploadFailed')))
|
||||||
|
} finally {
|
||||||
|
setUploadProgress(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// link gallery photos that were picked before save
|
// link gallery photos that were picked before save
|
||||||
if (pendingLinkIds.length > 0 && entryId) {
|
if (pendingLinkIds.length > 0 && entryId) {
|
||||||
@@ -2258,7 +2287,8 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
if (!files?.length) return
|
if (!files?.length) return
|
||||||
// Queue files locally until Save so cancel/close actually discards. This
|
// Queue files locally until Save so cancel/close actually discards. This
|
||||||
// keeps photo behavior consistent with text fields — no silent persistence.
|
// keeps photo behavior consistent with text fields — no silent persistence.
|
||||||
setPendingFiles(prev => [...prev, ...Array.from(files)])
|
const normalized = await normalizeImageFiles(files)
|
||||||
|
setPendingFiles(prev => [...prev, ...normalized])
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -2293,11 +2323,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
disabled={uploading}
|
disabled={saving}
|
||||||
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
className="flex-1 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-lg py-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{uploading ? (
|
{uploadProgress ? (
|
||||||
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploading')}</>
|
<><div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" /> {t('journey.editor.uploadingProgress', { done: String(uploadProgress.done), total: String(uploadProgress.total) })}</>
|
||||||
) : (
|
) : (
|
||||||
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
|
||||||
)}
|
)}
|
||||||
@@ -2323,7 +2353,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
{showGalleryPick && (
|
{showGalleryPick && (
|
||||||
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
|
<div className="mt-2 border border-zinc-200 dark:border-zinc-700 rounded-xl p-3 bg-zinc-50 dark:bg-zinc-800/50">
|
||||||
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
<div className="grid grid-cols-5 sm:grid-cols-6 gap-1.5 max-h-[160px] overflow-y-auto">
|
||||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).map(gp => (
|
{availableGalleryPhotos.map(gp => (
|
||||||
<div
|
<div
|
||||||
key={gp.id}
|
key={gp.id}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -2343,7 +2373,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
<img src={photoUrl(gp)} alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" onError={e => { const img = e.currentTarget; const orig = photoUrl(gp, 'original'); if (!img.src.includes('/original')) img.src = orig }} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)).length === 0 && (
|
{availableGalleryPhotos.length === 0 && (
|
||||||
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
<div className="col-span-full text-center py-3 text-[11px] text-zinc-400">{t('journey.editor.allPhotosAdded')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2378,8 +2408,13 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
<button
|
<button
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
await journeyApi.deletePhoto(p.id)
|
|
||||||
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
setPhotos(prev => prev.filter(x => x.id !== p.id))
|
||||||
|
if (entry.id > 0) {
|
||||||
|
// unlink from entry; gallery row is preserved
|
||||||
|
try { await journeyApi.unlinkPhoto(entry.id, p.id) } catch {}
|
||||||
|
} else {
|
||||||
|
setPendingLinkIds(prev => prev.filter(id => id !== p.id))
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
@@ -2952,7 +2987,7 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
|||||||
onClick={deleteLink}
|
onClick={deleteLink}
|
||||||
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
className="text-[11px] font-medium text-red-500 hover:text-red-600 self-start"
|
||||||
>
|
>
|
||||||
Remove share link
|
{t('share.deleteLink')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2960,11 +2995,12 @@ function JourneyShareSection({ journeyId }: { journeyId: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||||
journey: JourneyDetail
|
journey: JourneyDetail
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
onOpenInvite: () => void
|
onOpenInvite: () => void
|
||||||
|
onRefresh: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [title, setTitle] = useState(journey.title)
|
const [title, setTitle] = useState(journey.title)
|
||||||
@@ -2972,6 +3008,10 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||||
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
const [unlinkTarget, setUnlinkTarget] = useState<{ trip_id: number; title: string } | null>(null)
|
||||||
|
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false)
|
||||||
|
|
||||||
|
const isDirty = title !== journey.title || subtitle !== (journey.subtitle || '')
|
||||||
|
const handleClose = () => { if (isDirty) setShowDiscardConfirm(true); else onClose() }
|
||||||
const coverRef = useRef<HTMLInputElement>(null)
|
const coverRef = useRef<HTMLInputElement>(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -3030,12 +3070,12 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={handleClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
||||||
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[480px] w-full max-h-[85vh] md:max-h-[90vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">{t('journey.settings.title')}</h2>
|
||||||
<button onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
<button onClick={handleClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3131,7 +3171,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
try {
|
try {
|
||||||
await journeyApi.removeContributor(journey.id, c.user_id)
|
await journeyApi.removeContributor(journey.id, c.user_id)
|
||||||
toast.success(t('journey.contributors.removed'))
|
toast.success(t('journey.contributors.removed'))
|
||||||
onSaved()
|
onRefresh()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('journey.contributors.removeFailed'))
|
toast.error(t('journey.contributors.removeFailed'))
|
||||||
}
|
}
|
||||||
@@ -3182,7 +3222,7 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
{journey.status === 'archived' ? <ArchiveRestore size={14} /> : <Archive size={14} />}
|
||||||
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
<span className="hidden md:inline">{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
<button onClick={handleClose} className="h-9 px-3.5 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">{t('common.cancel')}</button>
|
||||||
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
<button onClick={handleSave} disabled={saving || !title.trim()} className="h-9 px-3.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40">
|
||||||
{saving ? t('common.saving') : t('common.save')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</button>
|
</button>
|
||||||
@@ -3229,6 +3269,16 @@ function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite }: {
|
|||||||
confirmLabel={t('common.delete')}
|
confirmLabel={t('common.delete')}
|
||||||
danger
|
danger
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showDiscardConfirm}
|
||||||
|
onClose={() => setShowDiscardConfirm(false)}
|
||||||
|
onConfirm={() => { setShowDiscardConfirm(false); onClose() }}
|
||||||
|
title={t('common.discardChanges')}
|
||||||
|
message={t('journey.editor.discardChangesConfirm')}
|
||||||
|
confirmLabel={t('common.discard')}
|
||||||
|
danger
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user