mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
Compare commits
35 Commits
v3.0.13
...
7c4bf3a5df
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c4bf3a5df | |||
| 3a837f8313 | |||
| 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 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ jobs:
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -102,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
|
||||||
|
|||||||
@@ -27,6 +27,33 @@ jobs:
|
|||||||
return;
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 455 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,524 @@
|
|||||||
|
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
|
||||||
|
|
||||||
|
# TREK 3.0.0
|
||||||
|
|
||||||
|
<video src="https://github.com/mauriceboe/trek-media/raw/main/.github/assets/TREK1.mp4" controls width="100%"></video>
|
||||||
|
|
||||||
|
> **The biggest TREK release to date.** A new Journey addon turns your trips into rich travel journals. Mapbox GL joins Leaflet as a first-class renderer. MCP gets a full OAuth 2.1 authorization server. Offline-first PWA, self-service password reset, and a dashboard redesigned from the ground up. Fifteen languages, top to bottom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
### Photos moved from Trip Planner to Journey
|
||||||
|
|
||||||
|
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
|
||||||
|
|
||||||
|
**What this means for you:**
|
||||||
|
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
|
||||||
|
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
|
||||||
|
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
|
||||||
|
|
||||||
|
### New Immich API Key Permissions Required
|
||||||
|
|
||||||
|
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
|
||||||
|
|
||||||
|
**Previous versions required:**
|
||||||
|
| Permission | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `user.read` | Connection test |
|
||||||
|
| `asset.read` | Browse photos by date, search |
|
||||||
|
| `asset.view` | Stream thumbnails |
|
||||||
|
| `asset.download` | Stream originals |
|
||||||
|
| `album.read` | List and browse albums |
|
||||||
|
| `timeline.read` | Browse timeline buckets |
|
||||||
|
|
||||||
|
**New in 3.0.0 — additionally required:**
|
||||||
|
| Permission | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `asset.upload` | Sync uploaded Journey photos to Immich |
|
||||||
|
|
||||||
|
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
|
||||||
|
|
||||||
|
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
|
||||||
|
|
||||||
|
### OIDC_ONLY deprecated
|
||||||
|
|
||||||
|
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
|
||||||
|
|
||||||
|
## Journey Addon — Travel Journal
|
||||||
|
|
||||||
|
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
|
||||||
|
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
|
||||||
|
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
|
||||||
|
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
|
||||||
|
- **Entry reorder** — move-up / move-down arrows on each entry (desktop), skipped on skeleton suggestions
|
||||||
|
- **Hide skeletons toggle** — per-contributor setting to focus on the written entries only
|
||||||
|
|
||||||
|
### Photos
|
||||||
|
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
|
||||||
|
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
|
||||||
|
- **EXIF metadata** — displayed in lightbox for Immich photos
|
||||||
|
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
|
||||||
|
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
|
||||||
|
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
|
||||||
|
- **Safari gallery picker fix** — repaired grid layout collapse on Safari (#717)
|
||||||
|
|
||||||
|
### Sharing & Export
|
||||||
|
- **Public share links** — token-based access with language picker, no login required
|
||||||
|
- **Public photo proxy** — validates share token instead of auth for photo streaming
|
||||||
|
- **Thumbnail size in public gallery** — grid loads thumbnails instead of originals, lightbox keeps originals (cuts bandwidth on shared links significantly)
|
||||||
|
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
|
||||||
|
|
||||||
|
### Collaboration
|
||||||
|
- **Contributors** — invite users as editors or viewers
|
||||||
|
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
|
||||||
|
- **Cover image** — upload or pick from journey photos
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
|
||||||
|
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
|
||||||
|
- **JourneyPublicPage** — public share view with language picker and read-only timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mapbox GL as a First-Class Renderer
|
||||||
|
|
||||||
|
Leaflet gets a sibling. Users can now switch the trip planner map to **Mapbox GL JS** for a proper 3D globe, terrain, and 3D buildings.
|
||||||
|
|
||||||
|
- **Settings toggle** — choose between Leaflet and Mapbox GL in Settings > Map
|
||||||
|
- **Globe projection** — smooth rotating globe when zoomed out, mercator when zoomed in
|
||||||
|
- **3D terrain and buildings** — enabled on Standard and Satellite styles, with custom 3D buildings in dark/light mode
|
||||||
|
- **Trip route, GPX geometries, place markers** — full feature parity with the Leaflet renderer
|
||||||
|
- **Transport reservations overlay** — great-circle arcs for flights/cruises, straight lines for trains/cars, clickable endpoint badges with IATA codes, rotating mid-arc stats label for flights. Honours the per-booking "show route" toggle in DayPlanSidebar
|
||||||
|
- **Auto-fit on load** — planner map zooms to the trip's places on initial render
|
||||||
|
- **Booking route label toggle** — separate setting to hide IATA labels on endpoint markers
|
||||||
|
- **Infrastructure** — WebAssembly allowed in CSP for Mapbox GL's 3D engine, PWA precache limit raised so the mapbox-gl bundle builds, Mapbox endpoints allowed in `connect-src` / `img-src`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP: OAuth 2.1 & Granular Scopes
|
||||||
|
|
||||||
|
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
|
||||||
|
|
||||||
|
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
|
||||||
|
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
|
||||||
|
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at `POST /oauth/register`, with strict redirect_uri validation (HTTPS / loopback / reverse-DNS private-use schemes only; rejects `javascript:` / `data:` / `file:` / etc.)
|
||||||
|
- **RFC 9728 Protected Resource Metadata** — `/.well-known/oauth-protected-resource` exposes the MCP endpoint's auth requirements for client auto-discovery
|
||||||
|
- **RFC 8707 audience binding** — tokens are audience-bound to `<app_url>/mcp` by default and validated on every MCP request
|
||||||
|
- **Consent screen** — user-facing scope selection with grouped permission display
|
||||||
|
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
|
||||||
|
- **Per-client rate limiting** — configurable rate limits per OAuth client
|
||||||
|
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
|
||||||
|
- **Compound tools** — single-call multi-step workflows (e.g. create day with places in one tool call, fetch full trip context) to reduce MCP round-trips
|
||||||
|
- **Surface alignment** — MCP tool schemas and responses kept in sync with the current app state (fewer drifted fields, correct enum sets)
|
||||||
|
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
|
||||||
|
- **Collab sub-feature gating** — MCP tools for chat/notes/polls respect the admin-level collab sub-feature toggles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Service Password Reset
|
||||||
|
|
||||||
|
Users can now reset their own password without admin intervention.
|
||||||
|
|
||||||
|
- **Email-based flow** — `/forgot-password` issues a single-use reset token delivered via SMTP (or logged to the server console if SMTP is not configured)
|
||||||
|
- **MFA-aware** — if the user has MFA enabled, the reset endpoint additionally verifies a TOTP code or backup code before rotating the password
|
||||||
|
- **Session invalidation** — resetting the password bumps `users.password_version`, which kicks every existing JWT, MCP static token, and OAuth bearer token for that user out in one shot
|
||||||
|
- **Server-side URL building** — the reset link is built from `APP_URL` / `ALLOWED_ORIGINS`, not from request headers, so a spoofed `Host` / `Origin` cannot redirect the link to an attacker-controlled domain
|
||||||
|
- **Rate limiting + audit** — per-IP rate limit on `/forgot-password`, all requests audited (including "no such user" so abuse is visible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard Redesign
|
||||||
|
|
||||||
|
The dashboard has been rebuilt with a mobile-first design language.
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
|
||||||
|
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
|
||||||
|
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
|
||||||
|
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
- **Unified header toolbar** — the dashboard, planner, vacay, and journey now share the same toolbar style
|
||||||
|
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
|
||||||
|
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
|
||||||
|
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
|
||||||
|
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
|
||||||
|
|
||||||
|
### Both
|
||||||
|
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
|
||||||
|
- **Dark mode** — full dark mode support across all new components
|
||||||
|
- **Shared PageSidebar** — Settings and Admin pages share a single sidebar component for layout consistency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PWA Offline Mode
|
||||||
|
|
||||||
|
TREK now works offline as a Progressive Web App with full data synchronization.
|
||||||
|
|
||||||
|
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
|
||||||
|
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
|
||||||
|
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
|
||||||
|
- **Offline trip planner** — full planner functionality with cached data
|
||||||
|
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
|
||||||
|
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
|
||||||
|
- **Idempotency keys** — prevents duplicate mutations on replay, scoped by `(key, user_id, method, path)` so the same key on different endpoints can't leak cached bodies
|
||||||
|
- **Offline document downloads** — document downloads work from the PWA cache when the network is unavailable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transport Reservations: Multi-Day + Map Visualization
|
||||||
|
|
||||||
|
- **Multi-day transport reservations** — flights, trains, cruises, car rentals can span multiple days with a dedicated modal and automatic route segmentation across the affected days (#384, #587)
|
||||||
|
- **Map visualization** — transport endpoints render on both Leaflet and Mapbox GL maps as clickable badges with IATA codes, great-circle arcs for flights/cruises, straight lines for trains/cars, and a rotating mid-arc stats label (IATA → IATA · distance · duration) on flights
|
||||||
|
- **Per-booking route toggle** — each booking in DayPlanSidebar has a "Show booking routes" button; connections only render when toggled on
|
||||||
|
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new `check_in_end` field (#366)
|
||||||
|
- **Cascaded delete** — deleting a reservation now cleans up related budget items, file links, and trip_items
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reservations Redesign
|
||||||
|
|
||||||
|
The reservations panel has been completely redesigned with a modern, unified layout.
|
||||||
|
|
||||||
|
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
|
||||||
|
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
|
||||||
|
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
|
||||||
|
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
|
||||||
|
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Apple Wallet pkpass Support
|
||||||
|
|
||||||
|
- **.pkpass MIME type** — server correctly serves `application/vnd.apple.pkpass` with the right Content-Type
|
||||||
|
- **Upload + download** — .pkpass files can be attached to bookings or places and opened directly in Apple Wallet on iOS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Todo Due-Date Reminders
|
||||||
|
|
||||||
|
- **Scheduler** — a new background scheduler scans todos with upcoming due dates and sends one reminder per item (default lead: 3 days)
|
||||||
|
- **No spam** — `todo_items.reminded_at` prevents re-sending a reminder for the same item on subsequent scheduler runs
|
||||||
|
- **Notification channel aware** — reminders respect the user's notification channel preferences (email, webhook, ntfy)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collab Sub-Feature Toggles
|
||||||
|
|
||||||
|
Individual collab sections can now be toggled on/off from the admin addons page (#604).
|
||||||
|
|
||||||
|
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
|
||||||
|
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
|
||||||
|
- **Mobile** — disabled tabs are hidden from the tab bar
|
||||||
|
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Place Import: KMZ/KML + Naver Maps + Selective GPX
|
||||||
|
|
||||||
|
Three ways to import places into your trips.
|
||||||
|
|
||||||
|
### KMZ/KML Import
|
||||||
|
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
|
||||||
|
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
|
||||||
|
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
|
||||||
|
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
|
||||||
|
|
||||||
|
### Naver Maps List Import
|
||||||
|
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
|
||||||
|
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
|
||||||
|
- **Pagination support** — handles large Naver Maps lists with automatic pagination
|
||||||
|
|
||||||
|
### Selective GPX/KML Element Import
|
||||||
|
- **Pick what to import** — import modal now lets you choose individual waypoints / tracks / folders instead of an all-or-nothing dump
|
||||||
|
- **Performance** — larger files (thousands of points) parse and render without freezing the UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Autocomplete
|
||||||
|
|
||||||
|
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
|
||||||
|
- **Google Places API** — primary autocomplete provider with location bias
|
||||||
|
- **Nominatim fallback** — free fallback when Google API key is not configured
|
||||||
|
- **Bounding box bias** — search results biased to the current map viewport
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ntfy Notification Channel
|
||||||
|
|
||||||
|
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
|
||||||
|
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
|
||||||
|
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
|
||||||
|
- **Full i18n** — ntfy strings translated in all 15 languages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login & Language
|
||||||
|
|
||||||
|
- **Language dropdown on login page** — users can select their preferred language before logging in
|
||||||
|
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
|
||||||
|
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Granular Auth Toggles
|
||||||
|
|
||||||
|
- **OIDC_ONLY replaced** — split into `DISABLE_LOCAL_LOGIN`, `DISABLE_LOCAL_REGISTRATION`, and `DISABLE_PASSWORD_CHANGE` for fine-grained control over authentication methods
|
||||||
|
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Synology Photos: OTP, SSL Skip & Session Management
|
||||||
|
|
||||||
|
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
|
||||||
|
- **Skip SSL verification** — toggle for self-signed certificates
|
||||||
|
- **Device ID persistence** — prevents repeated 2FA prompts
|
||||||
|
- **Session-cleared notification** — routed through unified notification system
|
||||||
|
- **Provider URL hint** — contextual help text for Synology URL format
|
||||||
|
- **Thumbnail size bump** — default thumbnail size raised from `sm` (240 px) to `m` (320 px) so grids no longer look pixelated on retina
|
||||||
|
- **Passphrase support** — shared-album links with passphrases work from the browse UI (#689)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Atlas Improvements
|
||||||
|
|
||||||
|
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
|
||||||
|
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
|
||||||
|
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
|
||||||
|
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n: Full 15-Language Coverage
|
||||||
|
|
||||||
|
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
|
||||||
|
- **Comprehensive audit** — every key translated natively, no English fallbacks
|
||||||
|
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
|
||||||
|
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
|
||||||
|
- **Mapbox GL settings** — localized labels for renderer toggle, style picker, 3D / quality switches
|
||||||
|
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vacay Improvements
|
||||||
|
|
||||||
|
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
|
||||||
|
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
|
||||||
|
- **Holiday overlap** — vacations can now be placed on public holidays
|
||||||
|
- **Today marker** — visual indicator for the current day in the calendar
|
||||||
|
- **Unified toolbar** — same header style as planner/dashboard/journey
|
||||||
|
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## iCal Export Improvements
|
||||||
|
|
||||||
|
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Budget Improvements
|
||||||
|
|
||||||
|
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
|
||||||
|
- **Category legend redesign** — prevents overflow on small screens (#564)
|
||||||
|
- **Comma decimal support** — pasting numbers with comma separators works correctly
|
||||||
|
- **Table alignment fix** — budget data rows and the "New Entry" row now share column widths (#759)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Packing List Improvements
|
||||||
|
|
||||||
|
- **Bulk import + template apply without full reload** — new items appear in place instead of triggering the trip loading screen (#760)
|
||||||
|
- **Reservation link cleanup** — packing items linked to deleted reservations stay in the list without the dangling reference
|
||||||
|
- **Bag tracking** — keep track of which items live in which bag, with optional weight tracking and per-bag totals
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planner & UX Improvements
|
||||||
|
|
||||||
|
- **Emil-style polish pass** — consistent transitions/animations across cards, hover states, and drawer sheets; shared components for toolbars and section headers
|
||||||
|
- **Planner drag-and-drop jank fix** — dragging places across days is smooth again on long trips
|
||||||
|
- **Unified toolbar header** — dashboard, planner, vacay, and journey share a single toolbar style for visual consistency
|
||||||
|
- **Places sidebar polish** — filter counts, compact select UI, tooltip component, "No Category" / "Uncategorized" filter (#607)
|
||||||
|
- **Dayplan toolbar polish** — cleaner alignment, weather archive fallback for past trips
|
||||||
|
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
|
||||||
|
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
|
||||||
|
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
|
||||||
|
- **File download button** — all file views now include a download button
|
||||||
|
- **Note modal** — no longer closes on outside click (#480)
|
||||||
|
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
|
||||||
|
- **Packing list menu** — no longer cut off by overflow (#557)
|
||||||
|
- **Trip date change** — preserving day content when date range changes
|
||||||
|
- **PDF export** — render restaurant, event, tour, and other reservation types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Panel Improvements
|
||||||
|
|
||||||
|
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
|
||||||
|
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
|
||||||
|
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
|
||||||
|
- **Naver List Import** — now always enabled, removed from addon toggles
|
||||||
|
- **Shared PageSidebar** — admin pages use the same sidebar layout as Settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile Improvements
|
||||||
|
|
||||||
|
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
|
||||||
|
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes, drop hero / inline tab-bar, eager map tiles, trimmed picker labels
|
||||||
|
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
|
||||||
|
- **Bottom nav dark mode** — consistent dark mode styling
|
||||||
|
- **Safe area support** — proper insets for iOS PWA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation & Wiki
|
||||||
|
|
||||||
|
- **Full GitHub Wiki** — 74 pages covering setup, deployment, addon docs, troubleshooting, API reference, and MCP
|
||||||
|
- **CI sync workflow** — `./wiki/**` in the main repo is auto-synced to the GitHub Wiki on push to `main`
|
||||||
|
- **README redesign** — Apple-style hero with animated video, feature tiles, and a screenshot gallery; hero video hosted externally so the repo stays lightweight
|
||||||
|
- **MCP compound tools doc** — `MCP.md` documents the compound / multi-step tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Fifth-pass internal audit. Critical + High + Medium findings addressed in one bundled PR:
|
||||||
|
|
||||||
|
- **JWT password_version gate** — a single `verifyJwtAndLoadUser` helper is now used by every auth surface (web session, MCP bearer, file download token, photo route, MFA policy). A password reset bumps `password_version` and invalidates every outstanding session/token for the user in one shot.
|
||||||
|
- **MFA policy via cookie** — `require_mfa` now applies to cookie-authenticated SPA sessions too (previously only the `Authorization` header was checked, so the whole SPA bypassed it).
|
||||||
|
- **OIDC id_token verification** — full JWKS-based signature verification (iss, aud, exp, nbf) plus `userinfo.sub == id_token.sub` cross-check. `kid` match is strict — no fallback to an arbitrary key.
|
||||||
|
- **OIDC invite redemption** — invite-token increment and user INSERT run in a single `db.transaction`; concurrent callbacks cannot double-redeem a single-use invite.
|
||||||
|
- **OAuth 2.1 DCR** — redirect_uri allowlist rejects `javascript:` / `data:` / `vbscript:` / `file:` / `blob:` / `about:` / `chrome:` and requires private-use schemes to be reverse-DNS (RFC 8252 §7.1).
|
||||||
|
- **OAuth audience binding** — `audience` defaults to the MCP endpoint when no `resource` parameter is sent, so new tokens always carry the correct audience claim.
|
||||||
|
- **HSTS on in production** — `NODE_ENV=production` is enough to enable HSTS (previously required `FORCE_HTTPS=true`). `includeSubDomains` stays off by default to avoid breaking apex-domain setups; opt in with `HSTS_INCLUDE_SUBDOMAINS=true`.
|
||||||
|
- **Cookie Secure behind proxies** — `trek_session` Secure flag is now derived from `req.secure` (Express's `trust proxy`-aware field), so instances behind Traefik / Caddy / Cloudflare Tunnel get Secure cookies without `FORCE_HTTPS`.
|
||||||
|
- **Share-token expiry** — public share tokens default to 90-day TTL. Existing tokens stay NULL (no expiry) so already-distributed links keep working.
|
||||||
|
- **Photo route scoping** — share tokens can only unlock photos that belong to the same trip as the token.
|
||||||
|
- **Bcrypt MFA backup codes** — backup codes are now bcrypt-hashed at rest. Legacy SHA-256 codes keep working until the user regenerates.
|
||||||
|
- **Demo-mode guards** — single `DEMO_EMAILS` registry fixes the drift where `demoUploadBlock` only matched the pre-rename `demo@nomad.app` string.
|
||||||
|
- **Filesystem safety** — `permanentDeleteFile` / `emptyTrash` / avatar cleanup use async `fs.promises.rm({ force: true })` and only drop the DB row when the on-disk unlink actually succeeded.
|
||||||
|
- **Idempotency store hardening** — key length capped at 128 chars, response bodies over 256 KiB not cached, primary key widened to `(key, user_id, method, path)` so the same key on a different endpoint does not replay an unrelated response.
|
||||||
|
- **Permissions cache invalidation** — `restoreFromZip` now drops the permissions cache after a DB swap.
|
||||||
|
- **Reset-URL source** — password-reset email URL is built from server-side `APP_URL` / `ALLOWED_ORIGINS`, never from request headers.
|
||||||
|
- **Critical DB indexes** — added `trips(user_id)`, `trips(created_at DESC)`, `photos(day_id/place_id)`, `reservations(day_id)`, `share_tokens(token)` and conditional `day_accommodations` / `notifications` indexes.
|
||||||
|
|
||||||
|
Upstream CVEs patched:
|
||||||
|
|
||||||
|
- **hono** 4.12.9 to 4.12.12 — directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), IP restriction bypass (CVE-2026-39409)
|
||||||
|
- **@hono/node-server** 1.19.11 to 1.19.13 — directory traversal (CVE-2026-39406)
|
||||||
|
- **nodemailer** 8.0.4 to 8.0.5 — CRLF injection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Fixed OIDC-only mode login/logout loop (#491)
|
||||||
|
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
|
||||||
|
- Fixed booking date handling and file auth bugs
|
||||||
|
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
|
||||||
|
- Fixed streaming response end on client disconnect during asset pipe
|
||||||
|
- Fixed per-day transport positions for multi-day reservations
|
||||||
|
- Fixed stale budget category reset when category no longer exists
|
||||||
|
- Fixed trip redirect to plan tab when active tab addon is disabled
|
||||||
|
- Fixed reservation price/budget field visibility when budget addon disabled
|
||||||
|
- Fixed HEIC photo rendering on non-Safari browsers
|
||||||
|
- Fixed CSP path matching for paths ending in /
|
||||||
|
- Fixed avatar URLs in notifications, admin panel, and budget
|
||||||
|
- Fixed budget member avatars lost after updating item fields
|
||||||
|
- Fixed budget table column alignment broken by `display: flex` on `<td>` (#759)
|
||||||
|
- Fixed collab notes line break preservation (#608)
|
||||||
|
- Fixed weather archive date handling for future trips (#599)
|
||||||
|
- Fixed duplicate skeleton entries for multi-day places (#606)
|
||||||
|
- Fixed ghost Gallery / `[Trip Photos]` entries in journal timeline and public share (#764)
|
||||||
|
- Fixed journey reorder arrows rendering on skeleton suggestions (#763)
|
||||||
|
- Fixed journey map OSM tile warning (#627)
|
||||||
|
- Fixed journey gallery picker grid collapse on Safari (#717)
|
||||||
|
- Fixed content divider placement in journal entries (#624)
|
||||||
|
- Fixed local photos wrong provider label (#625)
|
||||||
|
- Fixed Synology pagination and album scroll leak (#644)
|
||||||
|
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
|
||||||
|
- Fixed Nominatim User-Agent and error diagnostics
|
||||||
|
- Fixed map tooltips, journey creation, and contributor avatars
|
||||||
|
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
|
||||||
|
- Fixed stale accommodation_id on reservation update (#522)
|
||||||
|
- Fixed hardcoded Immich in toast — now uses provider_name
|
||||||
|
- Fixed MCP safeBroadcast recursive call bug
|
||||||
|
- Fixed MCP Zod v4 `z.record()` API compatibility in transport tool schemas
|
||||||
|
- Fixed Vite module preload polyfill CSP inline script violation
|
||||||
|
- Fixed PWA offline session redirect and file download auth (#505, #541)
|
||||||
|
- Fixed `FORCE_HTTPS` redirect applying to `/api/health`, breaking container health-checks
|
||||||
|
- Fixed journey bugs reported by @roel-de-vries (#722–#736)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
|
||||||
|
- **Helm chart** — moved to `charts/trek/`, published via helm-publisher action to `gh-pages`, `appVersion` used as default image tag
|
||||||
|
- **Docker** — workflow improvements, tag management cleanup, `server/data/airports.json` properly included in image after assets refactor
|
||||||
|
- **CI** — contributor workflow automation, `npm audit` removal from install steps, manual trigger for prerelease, client test job added alongside server tests with split coverage artifacts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
|
||||||
|
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
|
||||||
|
- **Journey** — 89.5% new code coverage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to everyone who contributed to this release:
|
||||||
|
|
||||||
|
- @mauriceboe
|
||||||
|
- @jubnl
|
||||||
|
- @gravitysc
|
||||||
|
- @luojiyin1987
|
||||||
|
- @marco783
|
||||||
|
- @isaiastavares
|
||||||
|
- @tiquis0290
|
||||||
|
- @xenocent
|
||||||
|
- @gfrcsd
|
||||||
|
- @roel-de-vries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Commits | 500+ |
|
||||||
|
| Merged PRs | 130+ |
|
||||||
|
| Files changed | 700+ |
|
||||||
|
| Lines added | 120,000+ |
|
||||||
|
| Contributors | 12+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull mauriceboe/trek:3.0.0
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run automatically on startup. No manual steps required.
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
|
||||||
|
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
|
||||||
|
3. Enable the Journey addon in Settings > Addons to start using the travel journal
|
||||||
|
4. Try the Mapbox GL renderer in Settings > Map if you want 3D terrain and a proper globe view (requires a free Mapbox access token)
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
|
||||||
|
<img width="5292" height="1404" alt="Release 2 9 0 (2)" src="https://github.com/user-attachments/assets/6ff67226-3535-444e-991f-0bc0352e22e7" />
|
||||||
|
|
||||||
|
# TREK 3.0.0
|
||||||
|
|
||||||
|
> **This is the biggest TREK release to date.** Journey turns your trips into rich travel journals. MCP gets full OAuth 2.1 security. The dashboard has been redesigned for mobile-first. And every corner of the app now speaks 15 languages natively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
### Photos moved from Trip Planner to Journey
|
||||||
|
|
||||||
|
In previous versions, Immich and Synology Photos were integrated directly into the Trip Planner via a "Photos" tab. **This tab has been removed.** Photos are now part of the new **Journey addon**, which is purpose-built for documenting your travels with stories, photos, and maps.
|
||||||
|
|
||||||
|
**What this means for you:**
|
||||||
|
- **No photos are lost.** The previous integration was read-only — TREK never uploaded to or deleted from your Immich/Synology library. Your photos remain untouched in your photo provider.
|
||||||
|
- **Previously linked trip photos are no longer displayed in the Trip Planner.** To view and organize your travel photos, enable the Journey addon (Settings > Addons) and create a Journey linked to your trip.
|
||||||
|
- **Journey brings a much richer photo experience:** upload photos directly to TREK, browse and import from Immich/Synology with duplicate detection, reorder photos, view EXIF metadata, and export everything as a PDF photo book.
|
||||||
|
|
||||||
|
### New Immich API Key Permissions Required
|
||||||
|
|
||||||
|
Journey introduces **photo upload sync** — when you upload a photo to a Journey entry, TREK can optionally sync it to your Immich library. This requires an additional Immich API permission that was not needed before.
|
||||||
|
|
||||||
|
**Previous versions required:**
|
||||||
|
| Permission | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `user.read` | Connection test |
|
||||||
|
| `asset.read` | Browse photos by date, search |
|
||||||
|
| `asset.view` | Stream thumbnails |
|
||||||
|
| `asset.download` | Stream originals |
|
||||||
|
| `album.read` | List and browse albums |
|
||||||
|
| `timeline.read` | Browse timeline buckets |
|
||||||
|
|
||||||
|
**New in 3.0.0 — additionally required:**
|
||||||
|
| Permission | Used for |
|
||||||
|
|---|---|
|
||||||
|
| `asset.upload` | Sync uploaded Journey photos to Immich |
|
||||||
|
|
||||||
|
> **How to update your Immich API key:** Go to your Immich instance > User Settings > API Keys. Edit your existing TREK key (or create a new one) and ensure `asset.upload` is enabled in addition to the existing permissions. If you don't plan to use Journey's upload sync, the old key will continue to work — the upload simply won't sync to Immich.
|
||||||
|
|
||||||
|
**No changes needed for Synology Photos** — Synology uses session-based authentication which inherits the user's full permissions.
|
||||||
|
|
||||||
|
### OIDC_ONLY deprecated
|
||||||
|
|
||||||
|
The `OIDC_ONLY` environment variable is deprecated. Replace with `DISABLE_LOCAL_LOGIN=true` + `DISABLE_LOCAL_REGISTRATION=true` for equivalent behavior. The old variable still works but will be removed in a future release.
|
||||||
|
|
||||||
|
---
|
||||||
|
<img width="5292" height="1404" alt="Release 2 9 0 (3)" src="https://github.com/user-attachments/assets/76976c02-dd81-49ab-83f5-e2221d6b018b" />
|
||||||
|
|
||||||
|
## Journey Addon — Travel Journal
|
||||||
|
|
||||||
|
The headline feature of 3.0.0. Journey is a new global addon that transforms your trips into magazine-style travel stories.
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- **5-table schema** — journeys, entries, photos, trips, contributors with full relational integrity
|
||||||
|
- **Trip-to-Journey sync engine** — link one or more trips to a journey; skeleton entries and photos are synced automatically
|
||||||
|
- **Timeline, Gallery, and Map views** — browse entries chronologically, as a photo grid, or on an interactive map with SVG pin markers
|
||||||
|
- **Entry editor** — markdown toolbar, custom date picker, location search (Nominatim/Google Maps), mood (Amazing/Good/Neutral/Rough), weather (Sunny to Snowy), and Pros & Cons sections
|
||||||
|
|
||||||
|
### Photos
|
||||||
|
- **Immich & Synology browser** — browse by trip dates, custom range, or album with duplicate detection
|
||||||
|
- **Photo upload** — direct upload with drag-and-drop, reorder (Make 1st), and delete
|
||||||
|
- **EXIF metadata** — displayed in lightbox for Immich photos
|
||||||
|
- **Thumbnail to original fallback** — seamless resolution upgrade everywhere
|
||||||
|
- **HEIC rendering fix** — serve fullsize thumbnail for original to fix HEIC rendering on non-Safari browsers
|
||||||
|
- **Contributor photo access** — invited contributors can view all journey photos even without their own Immich/Synology connection (owner credentials are used for the proxy)
|
||||||
|
|
||||||
|
### Sharing & Export
|
||||||
|
- **Public share links** — token-based access with language picker, no login required
|
||||||
|
- **Public photo proxy** — validates share token instead of auth for photo streaming
|
||||||
|
- **PDF photo book export** — Polarsteps-inspired layout with cover, day chapters, photo grids, and stories
|
||||||
|
|
||||||
|
### Collaboration
|
||||||
|
- **Contributors** — invite users as editors or viewers
|
||||||
|
- **Trip linking/unlinking** — manage synced trips from Journey Settings and Desktop Sidebar
|
||||||
|
- **Cover image** — upload or pick from journey photos
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **JourneyPage** — frontpage with hero card, active journey stats, trip suggestions ("Trip just ended — turn it into a Journey")
|
||||||
|
- **JourneyDetailPage** — full timeline/gallery/map with inline entry editing
|
||||||
|
- **JourneyPublicPage** — public share view with language picker and read-only timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP: OAuth 2.1 & Granular Scopes
|
||||||
|
|
||||||
|
MCP authentication has been completely rebuilt around the OAuth 2.1 specification.
|
||||||
|
|
||||||
|
- **OAuth 2.1 authorization server** — full PKCE flow with authorization codes, access tokens, refresh tokens, and token rotation with replay detection
|
||||||
|
- **Granular scopes** — 24 scopes across 11 groups (trips, places, atlas, packing, todos, budget, reservations, collab, notifications, vacay, geo/weather) with per-scope read/write/delete control
|
||||||
|
- **Dynamic Client Registration (DCR)** — RFC 7591 endpoint at POST /oauth/register for browser-initiated and public clients
|
||||||
|
- **Consent screen** — user-facing scope selection with grouped permission display
|
||||||
|
- **Admin panel** — OAuth sessions management in MCP Access panel with collapsible scope lists
|
||||||
|
- **Per-client rate limiting** — configurable rate limits per OAuth client
|
||||||
|
- **Addon gating** — MCP tools are only registered when their corresponding addon is enabled
|
||||||
|
- **Static token deprecation** — existing MCP tokens still work but surface deprecation notices; migration path to OAuth is documented
|
||||||
|
- **Security hardening** — Critical + High + Medium findings addressed (token storage, PKCE enforcement, scope validation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dashboard Redesign
|
||||||
|
|
||||||
|
The dashboard has been rebuilt with a mobile-first design language.
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
- **Greeting header** — "Good morning, {username}" with notification bell and avatar
|
||||||
|
- **Spotlight hero card** — the next upcoming or ongoing trip as a full-width hero with cover image, progress bar (for live trips), stats grid, and frosted-glass action buttons
|
||||||
|
- **Quick Actions** — New Trip, Currency Converter, Timezone as icon cards
|
||||||
|
- **Trip cards** — cover image with title overlay, status badge (In X days / Starts today / Ongoing / Completed), bottom stats (starts, duration, places, buddies)
|
||||||
|
|
||||||
|
### Desktop
|
||||||
|
- **Unified card design** — desktop grid cards now match the mobile card style (cover + title overlay + stats)
|
||||||
|
- **Hero card** — SpotlightCard with progress bar for ongoing trips, countdown for upcoming, stats grid
|
||||||
|
- **Hover actions** — edit/copy/archive/delete buttons appear on hover as frosted-glass icons
|
||||||
|
- **Status badges** — CircleCheck icon for completed trips, Clock for upcoming, pulsing dot for ongoing
|
||||||
|
|
||||||
|
### Both
|
||||||
|
- **BottomNav profile sheet** — slide-up sheet with user info, settings, admin, and logout
|
||||||
|
- **Dark mode** — full dark mode support across all new components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PWA Offline Mode
|
||||||
|
|
||||||
|
TREK now works offline as a Progressive Web App with full data synchronization.
|
||||||
|
|
||||||
|
- **IndexedDB (Dexie) storage** — trips, places, assignments, categories, tags, accommodations, reservations, budget items, packing items, files, and trip members cached locally
|
||||||
|
- **Offline mutation queue** — changes made offline are queued with monotonic timestamps and replayed on reconnect (FIFO)
|
||||||
|
- **Offline dashboard** — trip list loaded from Dexie when network is unavailable
|
||||||
|
- **Offline trip planner** — full planner functionality with cached data
|
||||||
|
- **Repo layer** — all data access routed through repository layer that falls back to offline storage
|
||||||
|
- **Offline banner** — visible indicator with safe-area-inset support for iOS PWA
|
||||||
|
- **Idempotency keys** — prevents duplicate mutations on replay (Migration 100)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reservations Redesign
|
||||||
|
|
||||||
|
The reservations panel has been completely redesigned with a modern, unified layout.
|
||||||
|
|
||||||
|
- **Unified toolbar** — title, type filter pills with count badges, and add button in one row with muted background
|
||||||
|
- **Type filters** — multi-select filter buttons (Flight, Hotel, Restaurant, etc.) with per-type count badges, persisted in sessionStorage
|
||||||
|
- **Responsive grid** — auto-fill layout with max 3 columns that fills full width
|
||||||
|
- **Card redesign** — status + type badge in header, labeled fields in rounded boxes, hover shadow
|
||||||
|
- **Check-in time ranges** — hotel bookings now support a check-in window (e.g. "15:00 -- 22:00") with a new check_in_end field (#366)
|
||||||
|
- **Mobile responsive** — filters hidden on mobile, booking code on separate row, weekday hidden in dates, reduced padding
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collab Sub-Feature Toggles
|
||||||
|
|
||||||
|
Individual collab sections can now be toggled on/off from the admin addons page (#604).
|
||||||
|
|
||||||
|
- **Admin UI** — sub-toggles for Chat, Notes, Polls, and What's Next under the Collab addon, with icons matching the collab panel tabs
|
||||||
|
- **Dynamic desktop layout** — Chat always stays at fixed 380px width; remaining active panels share space equally
|
||||||
|
- **Mobile** — disabled tabs are hidden from the tab bar
|
||||||
|
- **API** — GET/PUT /admin/collab-features endpoints stored in app_settings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Place Import: KMZ/KML & Naver Maps
|
||||||
|
|
||||||
|
Two new ways to import places into your trips.
|
||||||
|
|
||||||
|
### KMZ/KML Import
|
||||||
|
- **Unified file import modal** — drag-and-drop or file picker for KML, KMZ, and GPX files
|
||||||
|
- **KMZ unpacking** — extracts KML from ZIP archive with 50MB decompressed size limit
|
||||||
|
- **Folder-to-category mapping** — KML folders are automatically matched to TREK categories
|
||||||
|
- **Place deduplication** — skips places that already exist in the trip (by name + coordinates)
|
||||||
|
|
||||||
|
### Naver Maps List Import
|
||||||
|
- **Always enabled** — no longer requires addon toggle, available alongside Google Maps list import
|
||||||
|
- **Shortlink resolution** — resolves naver.me shortlinks to full list URLs
|
||||||
|
- **Pagination support** — handles large Naver Maps lists with automatic pagination
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Autocomplete
|
||||||
|
|
||||||
|
- **Real-time suggestions** — autocomplete suggestions appear as you type in the place search field
|
||||||
|
- **Google Places API** — primary autocomplete provider with location bias
|
||||||
|
- **Nominatim fallback** — free fallback when Google API key is not configured
|
||||||
|
- **Bounding box bias** — search results biased to the current map viewport
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ntfy Notification Channel
|
||||||
|
|
||||||
|
- **ntfy as first-class channel** — push notifications via any ntfy server (self-hosted or ntfy.sh)
|
||||||
|
- **Admin configuration** — server URL and topic configuration in admin panel with clear token button
|
||||||
|
- **Per-user opt-in** — users can enable/disable ntfy in their notification preferences
|
||||||
|
- **Full i18n** — ntfy strings translated in all 15 languages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Login & Language
|
||||||
|
|
||||||
|
- **Language dropdown on login page** — users can select their preferred language before logging in
|
||||||
|
- **Browser auto-detection** — language is automatically detected from browser settings on first visit
|
||||||
|
- **DEFAULT_LANGUAGE env var** — configurable default language for the instance, documented across all deployment configs (Docker, Helm, Synology)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Granular Auth Toggles
|
||||||
|
|
||||||
|
- **OIDC_ONLY replaced** — split into DISABLE_LOCAL_LOGIN, DISABLE_LOCAL_REGISTRATION, and DISABLE_PASSWORD_CHANGE for fine-grained control over authentication methods
|
||||||
|
- Allows mixed setups (e.g., OIDC + local admin account, or OIDC-only with no local registration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Synology Photos: OTP, SSL Skip & Session Management
|
||||||
|
|
||||||
|
- **OTP support** — one-time password field for 2FA-enabled Synology NAS
|
||||||
|
- **Skip SSL verification** — toggle for self-signed certificates
|
||||||
|
- **Device ID persistence** — prevents repeated 2FA prompts
|
||||||
|
- **Session-cleared notification** — routed through unified notification system
|
||||||
|
- **Provider URL hint** — contextual help text for Synology URL format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Atlas Improvements
|
||||||
|
|
||||||
|
- **Scoped region matching** — region name matching is now scoped by country to prevent cross-country false matches
|
||||||
|
- **Expanded country lookup tables** — more countries and regions recognized correctly, including A3 fallback for invalid ISO_A2 codes
|
||||||
|
- **Nominatim rate limiting** — shared throttle prevents 429 errors, background region fill, fetch timeout
|
||||||
|
- **Stadia Maps fix** — resolved 401 errors on journey and atlas maps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n: Full 15-Language Coverage
|
||||||
|
|
||||||
|
- **Indonesian added** — complete translation with full parity to English, bringing the total to 15 languages (EN, DE, FR, ES, IT, NL, PL, RU, ZH, ZH-TW, BR, CS, HU, AR, ID)
|
||||||
|
- **Comprehensive audit** — every key translated natively, no English fallbacks
|
||||||
|
- **OAuth scope labels** — all 24 scopes have localized names and descriptions
|
||||||
|
- **Journey addon** — complete coverage for all journal, editor, sharing, and PDF export strings
|
||||||
|
- **Ellipsis standardization** — all ellipsis characters normalized to three dots (...)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vacay Improvements
|
||||||
|
|
||||||
|
- **Trip indicator dots** — small blue dots on calendar days where trips are scheduled
|
||||||
|
- **Configurable week start** — choose Monday or Sunday as first day of the week (#224)
|
||||||
|
- **Holiday overlap** — vacations can now be placed on public holidays
|
||||||
|
- **Today marker** — visual indicator for the current day in the calendar
|
||||||
|
- **Bottom padding fix** — toolbar no longer overlaps the last row (#533)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## iCal Export Improvements
|
||||||
|
|
||||||
|
- **Day activities and notes** — iCal export now includes daily activities and notes, not just the trip dates (#375)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Budget Improvements
|
||||||
|
|
||||||
|
- **Drag-and-drop reorder** — budget categories and individual items can be reordered via drag-and-drop (#479)
|
||||||
|
- **Category legend redesign** — prevents overflow on small screens (#564)
|
||||||
|
- **Comma decimal support** — pasting numbers with comma separators works correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planner & UX Improvements
|
||||||
|
|
||||||
|
- **Collapsible day detail panel** — day detail panel can be collapsed/expanded in the planner
|
||||||
|
- **Uncategorized filter** — "No Category" option in category dropdown to find places without a category (#607)
|
||||||
|
- **Map multi-category filter** — filter syncs with map view for uncategorized places
|
||||||
|
- **Unplanned filter sync** — unplanned filter properly syncs with map markers (#385)
|
||||||
|
- **Place notes** — notes textarea in place edit form with proper display in inspector (#596)
|
||||||
|
- **Place deduplication** — Google Maps list re-import skips existing places (#543)
|
||||||
|
- **File download button** — all file views now include a download button
|
||||||
|
- **Note modal** — no longer closes on outside click (#480)
|
||||||
|
- **Google Maps links** — use place name + google_place_id for accurate links (#554)
|
||||||
|
- **Packing list menu** — no longer cut off by overflow (#557)
|
||||||
|
- **Trip date change** — preserving day content when date range changes
|
||||||
|
- **PDF export** — render restaurant, event, tour, and other reservation types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Panel Improvements
|
||||||
|
|
||||||
|
- **Collab sub-feature toggles** — individual toggles for Chat, Notes, Polls, What's Next
|
||||||
|
- **Photo provider icons** — Immich and Synology Photos SVG brand icons in addon manager
|
||||||
|
- **Bag tracking icon** — Luggage icon for the bag tracking sub-toggle
|
||||||
|
- **Naver List Import** — now always enabled, removed from addon toggles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile Improvements
|
||||||
|
|
||||||
|
- **Bottom nav fix** — prevent clipping of scrollable content and dialogs
|
||||||
|
- **Journey mobile** — compact add-entry button, scrollable settings dialog, iOS PWA fixes
|
||||||
|
- **Dashboard mobile** — spotlight trip in hero, smaller badges, check icon for completed
|
||||||
|
- **Bottom nav dark mode** — consistent dark mode styling
|
||||||
|
- **Safe area support** — proper insets for iOS PWA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
- **Backend** — expanded to ~87% coverage with comprehensive tests for OAuth, MCP tools, addon gating, services, and session management
|
||||||
|
- **Frontend** — expanded to ~82% coverage with tests for dashboard, planner, settings, admin panels, and component interactions
|
||||||
|
- **Journey** — 89.5% new code coverage
|
||||||
|
- **CI** — client test job added alongside server tests with split coverage artifacts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug Fixes
|
||||||
|
|
||||||
|
- Fixed OIDC-only mode login/logout loop (#491)
|
||||||
|
- Fixed dayplan duplicate reservation display, date off-by-one, and missing day_id on edit
|
||||||
|
- Fixed booking date handling and file auth bugs
|
||||||
|
- Fixed dayplan time-based auto-sort for places and free reorder for untimed
|
||||||
|
- Fixed streaming response end on client disconnect during asset pipe
|
||||||
|
- Fixed per-day transport positions for multi-day reservations
|
||||||
|
- Fixed stale budget category reset when category no longer exists
|
||||||
|
- Fixed trip redirect to plan tab when active tab addon is disabled
|
||||||
|
- Fixed reservation price/budget field visibility when budget addon disabled
|
||||||
|
- Fixed HEIC photo rendering on non-Safari browsers
|
||||||
|
- Fixed CSP path matching for paths ending in /
|
||||||
|
- Fixed avatar URLs in notifications, admin panel, and budget
|
||||||
|
- Fixed budget member avatars lost after updating item fields
|
||||||
|
- Fixed collab notes line break preservation (#608)
|
||||||
|
- Fixed weather archive date handling for future trips (#599)
|
||||||
|
- Fixed duplicate skeleton entries for multi-day places (#606)
|
||||||
|
- Fixed ghost Gallery entries in journal timeline and public share
|
||||||
|
- Fixed journey map OSM tile warning (#627)
|
||||||
|
- Fixed content divider placement in journal entries (#624)
|
||||||
|
- Fixed local photos wrong provider label (#625)
|
||||||
|
- Fixed Synology pagination and album scroll leak (#644)
|
||||||
|
- Fixed Stadia Maps 401 on journey and atlas maps (#640)
|
||||||
|
- Fixed Nominatim User-Agent and error diagnostics
|
||||||
|
- Fixed map tooltips, journey creation, and contributor avatars
|
||||||
|
- Fixed notifications SMTP error surfacing, webhook button label, backup timestamp (#537)
|
||||||
|
- Fixed stale accommodation_id on reservation update (#522)
|
||||||
|
- Fixed hardcoded Immich in toast — now uses provider_name
|
||||||
|
- Fixed MCP safeBroadcast recursive call bug
|
||||||
|
- Fixed Vite module preload polyfill CSP inline script violation
|
||||||
|
- Fixed PWA offline session redirect and file download auth (#505, #541)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **hono** 4.12.9 to 4.12.12 — fixes directory traversal (CVE-2026-39407, CVE-2026-39408), HTTP response splitting, improper input validation (CVE-2026-39410), and IP restriction bypass (CVE-2026-39409)
|
||||||
|
- **@hono/node-server** 1.19.11 to 1.19.13 — fixes directory traversal (CVE-2026-39406)
|
||||||
|
- **nodemailer** 8.0.4 to 8.0.5 — fixes CRLF injection
|
||||||
|
- **OAuth 2.1 hardening** — token storage, PKCE enforcement, scope intersection validation
|
||||||
|
- **Google Maps regex** — replaced too-permissive regex with safer utility function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- **Prerelease workflow** — automated prerelease pipeline with major version support, version propagation, and race/orphan tag protection
|
||||||
|
- **Helm chart** — moved to charts/trek/, published via helm-publisher action to gh-pages, appVersion used as default image tag
|
||||||
|
- **Docker** — workflow improvements, tag management cleanup
|
||||||
|
- **CI** — contributor workflow automation, npm audit removal from install steps, manual trigger for prerelease
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thanks to everyone who contributed to this release:
|
||||||
|
|
||||||
|
- @mauriceboe
|
||||||
|
- @jubnl
|
||||||
|
- @gravitysc
|
||||||
|
- @luojiyin1987
|
||||||
|
- @marco783
|
||||||
|
- @isaiastavares
|
||||||
|
- @tiquis0290
|
||||||
|
- @xenocent
|
||||||
|
- @gfrcsd
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stats
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Commits | 280+ |
|
||||||
|
| Merged PRs | 49 |
|
||||||
|
| Files changed | 500+ |
|
||||||
|
| Lines added | 108,000+ |
|
||||||
|
| Contributors | 12 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull mauriceboe/trek:3.0.0
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run automatically on startup. No manual steps required.
|
||||||
|
|
||||||
|
**Checklist:**
|
||||||
|
1. Update your Immich API key to include `asset.upload` (optional, only needed for Journey upload sync)
|
||||||
|
2. If using `OIDC_ONLY`, migrate to `DISABLE_LOCAL_LOGIN` + `DISABLE_LOCAL_REGISTRATION`
|
||||||
|
3. Enable the Journey addon in Settings > Addons to start using the travel journal
|
||||||
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
|||||||
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/NhZBDSd4qW). We'll let you know if the PR is wanted and give direction. PRs that show up without prior discussion will be closed
|
1. **Ask in Discord first** — Before writing any code, pitch your idea in the `#github-pr` channel on our [Discord server](https://discord.gg/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"]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<a href="https://demo-nomad.pakulat.org"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
<a href="https://demo.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" /></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>
|
||||||
|
|
||||||
|
|||||||
+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.
|
||||||
|
|||||||
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: 3.0.13
|
version: 3.0.22
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.13"
|
appVersion: "3.0.22"
|
||||||
|
|||||||
@@ -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": "3.0.13",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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={
|
||||||
|
|||||||
+131
-66
@@ -1,5 +1,7 @@
|
|||||||
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 { isReachable, probeNow } from '../sync/connectivity'
|
||||||
import en from '../i18n/translations/en'
|
import en from '../i18n/translations/en'
|
||||||
import br from '../i18n/translations/br'
|
import br from '../i18n/translations/br'
|
||||||
import de from '../i18n/translations/de'
|
import de from '../i18n/translations/de'
|
||||||
@@ -33,6 +35,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 +45,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 +71,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 + window.location.hash
|
// 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 +193,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 +205,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 +268,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 +366,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 +375,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,8 +408,20 @@ 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 }) =>
|
||||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
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),
|
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),
|
||||||
@@ -387,7 +452,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),
|
||||||
@@ -437,13 +502,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 = {
|
||||||
@@ -529,21 +594,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
|
||||||
@@ -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={{
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo
|
|||||||
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
|
||||||
|
|||||||
@@ -128,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', () => {
|
||||||
@@ -155,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', () => {
|
||||||
|
|||||||
@@ -225,44 +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) {
|
|
||||||
if (!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'
|
||||||
@@ -586,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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
|
|||||||
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 { 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 ''
|
||||||
@@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const phase = pdfGetSpanPhase(r, day.id)
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
const displayTime = pdfGetDisplayTime(r, day.id)
|
const displayTime = pdfGetDisplayTime(r, day.id)
|
||||||
const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
|
const time = splitReservationDateTime(displayTime).time ?? ''
|
||||||
const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}`
|
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};">
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -1311,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 />
|
||||||
@@ -1322,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 }}>
|
||||||
@@ -1342,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>
|
||||||
|
|
||||||
@@ -1380,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} />
|
||||||
@@ -1391,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 }}>
|
||||||
@@ -1411,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>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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 { 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,
|
||||||
@@ -57,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)
|
||||||
@@ -173,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" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
|
<div className="fixed z-50" 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%)',
|
||||||
@@ -288,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 }}>
|
||||||
@@ -305,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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
|||||||
|
|
||||||
import React, { useState, useEffect, useLayoutEffect, 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'
|
||||||
@@ -23,10 +23,15 @@ import { useCanDo } from '../../store/permissionsStore'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
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 },
|
||||||
@@ -179,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
|
||||||
@@ -195,6 +204,25 @@ interface DayPlanSidebarProps {
|
|||||||
onScrollTopChange?: (top: number) => void
|
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({
|
||||||
tripId,
|
tripId,
|
||||||
trip, days, places, categories, assignments,
|
trip, days, places, categories, assignments,
|
||||||
@@ -211,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,
|
||||||
@@ -228,6 +260,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const ctxMenu = useContextMenu()
|
const ctxMenu = useContextMenu()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
|
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
|
||||||
const tripActions = useRef(useTripStore.getState()).current
|
const tripActions = useRef(useTripStore.getState()).current
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const canEditDays = can('day_edit', trip)
|
const canEditDays = can('day_edit', trip)
|
||||||
@@ -246,6 +279,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)
|
||||||
@@ -362,26 +397,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
|
||||||
@@ -406,27 +421,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 (!TRANSPORT_TYPES.has(r.type)) 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) => {
|
||||||
@@ -446,20 +442,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
|
||||||
@@ -501,64 +483,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
|
||||||
@@ -570,6 +502,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 || !routeCalcEnabled || !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, routeCalcEnabled, routeShown, routeProfile, mergedItemsMap])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
_openAddNote(dayId, getMergedItems, (id) => {
|
_openAddNote(dayId, getMergedItems, (id) => {
|
||||||
@@ -890,13 +858,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()
|
||||||
@@ -1145,6 +1106,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) }}
|
||||||
@@ -1164,16 +1127,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 ? (
|
||||||
@@ -1191,40 +1172,27 @@ 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 => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
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
|
||||||
@@ -1243,13 +1211,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>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -1259,41 +1225,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 */}
|
||||||
@@ -1585,15 +1560,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 && ` – ${(() => {
|
if (!st && !et) return null
|
||||||
const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
|
return (
|
||||||
return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
<span style={{ fontWeight: 400 }}>
|
||||||
})()}`}
|
{st ? formatTime(st, locale, timeFormat) : ''}
|
||||||
</span>
|
{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
|
||||||
@@ -1703,6 +1680,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1752,6 +1730,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)
|
||||||
@@ -1820,18 +1802,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' }}>
|
||||||
@@ -1880,8 +1864,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
|
||||||
@@ -1978,7 +1971,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
|
||||||
@@ -1994,6 +1987,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}` && (
|
||||||
@@ -2004,15 +2000,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',
|
||||||
@@ -2021,14 +2023,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -2192,13 +2215,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={{
|
||||||
|
|||||||
@@ -10,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()
|
||||||
|
|
||||||
@@ -169,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
|
||||||
@@ -344,7 +348,7 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -378,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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Markdown from 'react-markdown'
|
|||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import remarkBreaks from 'remark-breaks'
|
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
|
||||||
@@ -99,17 +100,13 @@ 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
|
||||||
|
|
||||||
@@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
</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' }}>
|
||||||
{(() => {
|
{fmtDate(startDt.date!)}
|
||||||
const endDatePart = r.reservation_end_time
|
{endDt.date && endDt.date !== startDt.date && (
|
||||||
? r.reservation_end_time.includes('T')
|
<> – {fmtDate(endDt.date)}</>
|
||||||
? r.reservation_end_time.split('T')[0]
|
)}
|
||||||
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
|
</div>
|
||||||
? r.reservation_end_time
|
|
||||||
: null
|
|
||||||
: null
|
|
||||||
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
|
|
||||||
})() && (
|
|
||||||
<> – {fmtDate(r.reservation_end_time)}</>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
@@ -316,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' }}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { formatDate } from '../../utils/formatters'
|
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||||
@@ -141,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 || '',
|
||||||
@@ -179,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> = {}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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,7 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
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'
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
|||||||
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
||||||
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
* driving/walking durations via OSRM. 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[]>([])
|
||||||
@@ -22,7 +22,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
|
|
||||||
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 +68,52 @@ 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 (segments.length === 0 && geocodedWaypoints.length < 2) {
|
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||||
setRoute(null); setRouteSegments([]); return
|
|
||||||
}
|
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||||
setRoute(segments.length > 0 ? segments : null)
|
// OSRM road geometry. If route calc is disabled, keep the straight lines.
|
||||||
|
setRoute(straightLines())
|
||||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
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])
|
}, [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 +135,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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import es from './translations/es'
|
|||||||
import fr from './translations/fr'
|
import fr from './translations/fr'
|
||||||
import hu from './translations/hu'
|
import hu from './translations/hu'
|
||||||
import it from './translations/it'
|
import it from './translations/it'
|
||||||
|
import tr from './translations/tr'
|
||||||
import ru from './translations/ru'
|
import ru from './translations/ru'
|
||||||
import zh from './translations/zh'
|
import zh from './translations/zh'
|
||||||
import zhTw from './translations/zhTw'
|
import zhTw from './translations/zhTw'
|
||||||
@@ -15,6 +16,10 @@ import ar from './translations/ar'
|
|||||||
import br from './translations/br'
|
import br from './translations/br'
|
||||||
import cs from './translations/cs'
|
import cs from './translations/cs'
|
||||||
import pl from './translations/pl'
|
import pl from './translations/pl'
|
||||||
|
import ja from './translations/ja'
|
||||||
|
import ko from './translations/ko'
|
||||||
|
import uk from './translations/uk'
|
||||||
|
import gr from './translations/gr'
|
||||||
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
||||||
|
|
||||||
export { SUPPORTED_LANGUAGES }
|
export { SUPPORTED_LANGUAGES }
|
||||||
@@ -23,7 +28,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
|
|||||||
|
|
||||||
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
||||||
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
||||||
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
|
de, en, es, fr, hu, it, tr, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja, ko, uk, gr,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
||||||
@@ -38,7 +43,7 @@ export function getLocaleForLanguage(language: string): string {
|
|||||||
|
|
||||||
export function getIntlLanguage(language: string): string {
|
export function getIntlLanguage(language: string): string {
|
||||||
if (language === 'br') return 'pt-BR'
|
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'
|
return ['de', 'es', 'fr', 'hu', 'it', 'tr', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja', 'ko', 'uk', 'gr'].includes(language) ? language : 'en'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRtlLanguage(language: string): boolean {
|
export function isRtlLanguage(language: string): boolean {
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ export const SUPPORTED_LANGUAGES = [
|
|||||||
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
||||||
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
||||||
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
||||||
|
{ value: 'tr', label: 'Türkçe', locale: 'tr-TR' },
|
||||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
||||||
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
||||||
|
{ value: 'ja', label: '日本語', locale: 'ja-JP' },
|
||||||
|
{ value: 'ko', label: '한국어', locale: 'ko-KR' },
|
||||||
|
{ value: 'uk', label: 'Українська', locale: 'uk-UA' },
|
||||||
|
{ value: 'gr', label: 'Ελληνικά', locale: 'el-GR' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
|
'settings.oauth.toast.revoked': 'تم إلغاء الجلسة',
|
||||||
'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
|
'settings.oauth.toast.revokeError': 'فشل إلغاء الجلسة',
|
||||||
'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
|
'settings.oauth.toast.rotateError': 'فشل تجديد سر العميل',
|
||||||
|
'settings.oauth.modal.machineClient': 'عميل آلي (بدون تسجيل دخول عبر المتصفح)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'استخدام منحة client_credentials — لا تحتاج إلى عناوين إعادة التوجيه. يُصدر الرمز المميز مباشرةً عبر client_id + client_secret ويعمل بصلاحياتك ضمن النطاقات المحددة.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'للحصول على رمز مميز: POST /oauth/token مع grant_type=client_credentials وclient_id وclient_secret. بدون متصفح، بدون رمز تحديث.',
|
||||||
|
'settings.oauth.badge.machine': 'آلي',
|
||||||
'settings.account': 'الحساب',
|
'settings.account': 'الحساب',
|
||||||
'settings.about': 'حول',
|
'settings.about': 'حول',
|
||||||
'settings.about.reportBug': 'الإبلاغ عن خطأ',
|
'settings.about.reportBug': 'الإبلاغ عن خطأ',
|
||||||
@@ -1674,6 +1678,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'فشل في الحذف',
|
'journey.settings.failedToDelete': 'فشل في الحذف',
|
||||||
'journey.entries.deleteTitle': 'حذف الإدخال',
|
'journey.entries.deleteTitle': 'حذف الإدخال',
|
||||||
'journey.photosUploaded': 'تم رفع {count} صورة',
|
'journey.photosUploaded': 'تم رفع {count} صورة',
|
||||||
|
'journey.photosUploadFailed': 'فشل رفع بعض الصور',
|
||||||
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
'journey.photosAdded': 'تمت إضافة {count} صورة',
|
||||||
'journey.picker.tripPeriod': 'فترة الرحلة',
|
'journey.picker.tripPeriod': 'فترة الرحلة',
|
||||||
'journey.picker.dateRange': 'نطاق التاريخ',
|
'journey.picker.dateRange': 'نطاق التاريخ',
|
||||||
@@ -1705,8 +1710,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
'journey.editor.discardChangesConfirm': 'لديك تغييرات غير محفوظة. هل تريد تجاهلها؟',
|
||||||
|
'journey.editor.uploadFailed': 'فشل رفع الصور',
|
||||||
'journey.editor.uploadPhotos': 'رفع صور',
|
'journey.editor.uploadPhotos': 'رفع صور',
|
||||||
'journey.editor.uploading': '...جارٍ الرفع',
|
'journey.editor.uploading': '...جارٍ الرفع',
|
||||||
|
'journey.editor.uploadingProgress': 'جارٍ الرفع {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': 'فشل رفع {failed} من {total} — احفظ مجدداً للمحاولة',
|
||||||
'journey.editor.fromGallery': 'من المعرض',
|
'journey.editor.fromGallery': 'من المعرض',
|
||||||
'journey.editor.addAnother': 'إضافة آخر',
|
'journey.editor.addAnother': 'إضافة آخر',
|
||||||
'journey.editor.makeFirst': 'جعله الأول',
|
'journey.editor.makeFirst': 'جعله الأول',
|
||||||
@@ -2143,6 +2151,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
|
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
|
||||||
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
|
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'إجراء مطلوب: تعارض في حسابات المستخدمين',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'اكتشف ترقية 3.0.14 تعارضًا في أسماء مستخدمين أو بريد إلكتروني ناتجًا عن مسافات بيضاء في بداية أو نهاية القيم المخزنة. تمت إعادة تسمية الحسابات المتأثرة تلقائيًا. تحقق من سجلات الخادم بحثًا عن أسطر تبدأ بـ **[migration] WHITESPACE COLLISION** لتحديد الحسابات التي تحتاج إلى مراجعة.',
|
||||||
'transport.addTransport': 'إضافة وسيلة نقل',
|
'transport.addTransport': 'إضافة وسيلة نقل',
|
||||||
'transport.modalTitle.create': 'إضافة وسيلة نقل',
|
'transport.modalTitle.create': 'إضافة وسيلة نقل',
|
||||||
'transport.modalTitle.edit': 'تعديل وسيلة النقل',
|
'transport.modalTitle.edit': 'تعديل وسيلة النقل',
|
||||||
|
|||||||
@@ -402,6 +402,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sessão revogada',
|
'settings.oauth.toast.revoked': 'Sessão revogada',
|
||||||
'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
|
'settings.oauth.toast.revokeError': 'Falha ao revogar sessão',
|
||||||
'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
|
'settings.oauth.toast.rotateError': 'Falha ao renovar segredo do cliente',
|
||||||
|
'settings.oauth.modal.machineClient': 'Cliente de máquina (sem login no navegador)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Usa o grant client_credentials — sem URIs de redirecionamento. O token é emitido diretamente via client_id + client_secret e age como você dentro dos escopos selecionados.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Obter token: POST /oauth/token com grant_type=client_credentials, client_id e client_secret. Sem navegador, sem refresh token.',
|
||||||
|
'settings.oauth.badge.machine': 'máquina',
|
||||||
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
|
'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.',
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
@@ -2077,8 +2081,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'lugares',
|
'journey.synced.places': 'lugares',
|
||||||
'journey.synced.synced': 'sincronizado',
|
'journey.synced.synced': 'sincronizado',
|
||||||
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
'journey.editor.discardChangesConfirm': 'Você tem alterações não salvas. Descartá-las?',
|
||||||
|
'journey.editor.uploadFailed': 'Falha ao enviar fotos',
|
||||||
'journey.editor.uploadPhotos': 'Enviar fotos',
|
'journey.editor.uploadPhotos': 'Enviar fotos',
|
||||||
'journey.editor.uploading': 'Enviando...',
|
'journey.editor.uploading': 'Enviando...',
|
||||||
|
'journey.editor.uploadingProgress': 'Enviando {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos falharam — salve novamente para tentar',
|
||||||
'journey.editor.fromGallery': 'Da galeria',
|
'journey.editor.fromGallery': 'Da galeria',
|
||||||
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
'journey.editor.allPhotosAdded': 'Todas as fotos já foram adicionadas',
|
||||||
'journey.editor.writeStory': 'Escreva sua história...',
|
'journey.editor.writeStory': 'Escreva sua história...',
|
||||||
@@ -2169,6 +2176,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Falha ao excluir',
|
'journey.settings.failedToDelete': 'Falha ao excluir',
|
||||||
'journey.entries.deleteTitle': 'Excluir entrada',
|
'journey.entries.deleteTitle': 'Excluir entrada',
|
||||||
'journey.photosUploaded': '{count} fotos enviadas',
|
'journey.photosUploaded': '{count} fotos enviadas',
|
||||||
|
'journey.photosUploadFailed': 'Algumas fotos não foram enviadas',
|
||||||
'journey.photosAdded': '{count} fotos adicionadas',
|
'journey.photosAdded': '{count} fotos adicionadas',
|
||||||
'journey.public.notFound': 'Não encontrado',
|
'journey.public.notFound': 'Não encontrado',
|
||||||
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
|
'journey.public.notFoundMessage': 'Esta jornada não existe ou o link expirou.',
|
||||||
@@ -2346,6 +2354,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
|
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
|
||||||
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
|
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Ação necessária: conflito de conta de usuário',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'A atualização 3.0.14 detectou um ou mais conflitos de nome de usuário ou e-mail causados por espaços em branco no início ou fim dos valores armazenados. As contas afetadas foram renomeadas automaticamente. Verifique os logs do servidor por linhas começando com **[migration] WHITESPACE COLLISION** para identificar quais contas precisam de revisão.',
|
||||||
'transport.addTransport': 'Adicionar transporte',
|
'transport.addTransport': 'Adicionar transporte',
|
||||||
'transport.modalTitle.create': 'Adicionar transporte',
|
'transport.modalTitle.create': 'Adicionar transporte',
|
||||||
'transport.modalTitle.edit': 'Editar transporte',
|
'transport.modalTitle.edit': 'Editar transporte',
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Relace odvolána',
|
'settings.oauth.toast.revoked': 'Relace odvolána',
|
||||||
'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
|
'settings.oauth.toast.revokeError': 'Odvolání relace se nezdařilo',
|
||||||
'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
|
'settings.oauth.toast.rotateError': 'Obnovení tajného klíče klienta se nezdařilo',
|
||||||
|
'settings.oauth.modal.machineClient': 'Strojový klient (bez přihlášení v prohlížeči)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Používá grant client_credentials — bez URI pro přesměrování. Token je vydán přímo přes client_id + client_secret a funguje jako vy v rámci vybraných oborů.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Získat token: POST /oauth/token s grant_type=client_credentials, client_id a client_secret. Bez prohlížeče, bez obnovovacího tokenu.',
|
||||||
|
'settings.oauth.badge.machine': 'strojový',
|
||||||
'settings.account': 'Účet',
|
'settings.account': 'Účet',
|
||||||
'settings.about': 'O aplikaci',
|
'settings.about': 'O aplikaci',
|
||||||
'settings.about.reportBug': 'Nahlásit chybu',
|
'settings.about.reportBug': 'Nahlásit chybu',
|
||||||
@@ -2082,8 +2086,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'místa',
|
'journey.synced.places': 'místa',
|
||||||
'journey.synced.synced': 'synchronizováno',
|
'journey.synced.synced': 'synchronizováno',
|
||||||
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
'journey.editor.discardChangesConfirm': 'Máte neuložené změny. Zahodit?',
|
||||||
|
'journey.editor.uploadFailed': 'Nahrávání fotek selhalo',
|
||||||
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
'journey.editor.uploadPhotos': 'Nahrát fotky',
|
||||||
'journey.editor.uploading': 'Nahrávání...',
|
'journey.editor.uploading': 'Nahrávání...',
|
||||||
|
'journey.editor.uploadingProgress': 'Nahrávání {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} z {total} fotek selhalo — uložte znovu pro opakování',
|
||||||
'journey.editor.fromGallery': 'Z galerie',
|
'journey.editor.fromGallery': 'Z galerie',
|
||||||
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
|
'journey.editor.allPhotosAdded': 'Všechny fotky již přidány',
|
||||||
'journey.editor.writeStory': 'Napište svůj příběh...',
|
'journey.editor.writeStory': 'Napište svůj příběh...',
|
||||||
@@ -2174,6 +2181,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Smazání se nezdařilo',
|
'journey.settings.failedToDelete': 'Smazání se nezdařilo',
|
||||||
'journey.entries.deleteTitle': 'Smazat záznam',
|
'journey.entries.deleteTitle': 'Smazat záznam',
|
||||||
'journey.photosUploaded': '{count} fotografií nahráno',
|
'journey.photosUploaded': '{count} fotografií nahráno',
|
||||||
|
'journey.photosUploadFailed': 'Některé fotky se nepodařilo nahrát',
|
||||||
'journey.photosAdded': '{count} fotografií přidáno',
|
'journey.photosAdded': '{count} fotografií přidáno',
|
||||||
'journey.public.notFound': 'Nenalezeno',
|
'journey.public.notFound': 'Nenalezeno',
|
||||||
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
|
'journey.public.notFoundMessage': 'Tento cestovní deník neexistuje nebo odkaz vypršel.',
|
||||||
@@ -2350,6 +2358,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
||||||
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Vyžadována akce: konflikt uživatelského účtu',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'Aktualizace 3.0.14 zjistila jeden nebo více konfliktů uživatelského jména nebo e-mailu způsobených mezerami na začátku nebo konci uložených hodnot. Dotčené účty byly automaticky přejmenovány. Zkontrolujte protokoly serveru na řádky začínající **[migration] WHITESPACE COLLISION** a zjistěte, které účty vyžadují kontrolu.',
|
||||||
'transport.addTransport': 'Přidat dopravu',
|
'transport.addTransport': 'Přidat dopravu',
|
||||||
'transport.modalTitle.create': 'Přidat dopravu',
|
'transport.modalTitle.create': 'Přidat dopravu',
|
||||||
'transport.modalTitle.edit': 'Upravit dopravu',
|
'transport.modalTitle.edit': 'Upravit dopravu',
|
||||||
|
|||||||
@@ -330,6 +330,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Session widerrufen',
|
'settings.oauth.toast.revoked': 'Session widerrufen',
|
||||||
'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
|
'settings.oauth.toast.revokeError': 'Session konnte nicht widerrufen werden',
|
||||||
'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
|
'settings.oauth.toast.rotateError': 'Client-Secret konnte nicht erneuert werden',
|
||||||
|
'settings.oauth.modal.machineClient': 'Maschineller Client (kein Browser-Login)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Verwendet den client_credentials Grant — keine Redirect-URIs erforderlich. Das Token wird direkt über client_id + client_secret ausgestellt und handelt in Ihrem Namen innerhalb der gewählten Scopes.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Token abrufen: POST /oauth/token mit grant_type=client_credentials, client_id und client_secret. Kein Browser, kein Refresh-Token.',
|
||||||
|
'settings.oauth.badge.machine': 'Maschine',
|
||||||
'settings.account': 'Konto',
|
'settings.account': 'Konto',
|
||||||
'settings.about': 'Über',
|
'settings.about': 'Über',
|
||||||
'settings.about.reportBug': 'Bug melden',
|
'settings.about.reportBug': 'Bug melden',
|
||||||
@@ -2085,8 +2089,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'Orte',
|
'journey.synced.places': 'Orte',
|
||||||
'journey.synced.synced': 'synchronisiert',
|
'journey.synced.synced': 'synchronisiert',
|
||||||
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
'journey.editor.discardChangesConfirm': 'Du hast ungespeicherte Änderungen. Verwerfen?',
|
||||||
|
'journey.editor.uploadFailed': 'Foto-Upload fehlgeschlagen',
|
||||||
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
'journey.editor.uploadPhotos': 'Fotos hochladen',
|
||||||
'journey.editor.uploading': 'Hochladen...',
|
'journey.editor.uploading': 'Hochladen...',
|
||||||
|
'journey.editor.uploadingProgress': 'Hochladen {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} von {total} Fotos fehlgeschlagen — erneut speichern zum Wiederholen',
|
||||||
'journey.editor.fromGallery': 'Aus Galerie',
|
'journey.editor.fromGallery': 'Aus Galerie',
|
||||||
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
'journey.editor.allPhotosAdded': 'Alle Fotos bereits hinzugefügt',
|
||||||
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
|
'journey.editor.writeStory': 'Erzähle deine Geschichte...',
|
||||||
@@ -2181,6 +2188,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
|
'journey.settings.failedToDelete': 'Löschen fehlgeschlagen',
|
||||||
'journey.entries.deleteTitle': 'Eintrag löschen',
|
'journey.entries.deleteTitle': 'Eintrag löschen',
|
||||||
'journey.photosUploaded': '{count} Fotos hochgeladen',
|
'journey.photosUploaded': '{count} Fotos hochgeladen',
|
||||||
|
'journey.photosUploadFailed': 'Einige Fotos konnten nicht hochgeladen werden',
|
||||||
'journey.photosAdded': '{count} Fotos hinzugefügt',
|
'journey.photosAdded': '{count} Fotos hinzugefügt',
|
||||||
'journey.public.notFound': 'Nicht gefunden',
|
'journey.public.notFound': 'Nicht gefunden',
|
||||||
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
|
'journey.public.notFoundMessage': 'Diese Journey existiert nicht oder der Link ist abgelaufen.',
|
||||||
@@ -2356,6 +2364,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — persönlicher Dank
|
// System notices — persönlicher Dank
|
||||||
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
||||||
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Aktion erforderlich: Benutzerkontokonflikt',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'Das 3.0.14-Upgrade hat einen oder mehrere Konflikte bei Benutzernamen oder E-Mail-Adressen festgestellt, die durch führende oder nachgestellte Leerzeichen in gespeicherten Konten verursacht wurden. Betroffene Konten wurden automatisch umbenannt. Prüfe die Serverprotokolle auf Zeilen, die mit **[migration] WHITESPACE COLLISION** beginnen, um die betroffenen Konten zu identifizieren.',
|
||||||
'transport.addTransport': 'Transport hinzufügen',
|
'transport.addTransport': 'Transport hinzufügen',
|
||||||
'transport.modalTitle.create': 'Transport hinzufügen',
|
'transport.modalTitle.create': 'Transport hinzufügen',
|
||||||
'transport.modalTitle.edit': 'Transport bearbeiten',
|
'transport.modalTitle.edit': 'Transport bearbeiten',
|
||||||
|
|||||||
@@ -403,6 +403,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Session revoked',
|
'settings.oauth.toast.revoked': 'Session revoked',
|
||||||
'settings.oauth.toast.revokeError': 'Failed to revoke session',
|
'settings.oauth.toast.revokeError': 'Failed to revoke session',
|
||||||
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
|
'settings.oauth.toast.rotateError': 'Failed to rotate client secret',
|
||||||
|
'settings.oauth.modal.machineClient': 'Machine client (no browser login)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Use client_credentials grant — no redirect URIs needed. The token is issued directly via client_id + client_secret and acts as you within the selected scopes.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Get a token: POST /oauth/token with grant_type=client_credentials, client_id, and client_secret. No browser, no refresh token.',
|
||||||
|
'settings.oauth.badge.machine': 'machine',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.about': 'About',
|
'settings.about': 'About',
|
||||||
'settings.about.reportBug': 'Report a Bug',
|
'settings.about.reportBug': 'Report a Bug',
|
||||||
@@ -2111,8 +2115,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
'journey.editor.discardChangesConfirm': 'You have unsaved changes. Discard them?',
|
||||||
|
'journey.editor.uploadFailed': 'Photo upload failed',
|
||||||
'journey.editor.uploadPhotos': 'Upload photos',
|
'journey.editor.uploadPhotos': 'Upload photos',
|
||||||
'journey.editor.uploading': 'Uploading...',
|
'journey.editor.uploading': 'Uploading...',
|
||||||
|
'journey.editor.uploadingProgress': 'Uploading {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} of {total} photos failed — save again to retry',
|
||||||
'journey.editor.fromGallery': 'From Gallery',
|
'journey.editor.fromGallery': 'From Gallery',
|
||||||
'journey.editor.allPhotosAdded': 'All photos already added',
|
'journey.editor.allPhotosAdded': 'All photos already added',
|
||||||
'journey.editor.writeStory': 'Write your story...',
|
'journey.editor.writeStory': 'Write your story...',
|
||||||
@@ -2219,6 +2226,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Failed to delete',
|
'journey.settings.failedToDelete': 'Failed to delete',
|
||||||
'journey.entries.deleteTitle': 'Delete Entry',
|
'journey.entries.deleteTitle': 'Delete Entry',
|
||||||
'journey.photosUploaded': '{count} photos uploaded',
|
'journey.photosUploaded': '{count} photos uploaded',
|
||||||
|
'journey.photosUploadFailed': 'Some photos failed to upload',
|
||||||
'journey.photosAdded': '{count} photos added',
|
'journey.photosAdded': '{count} photos added',
|
||||||
|
|
||||||
// Journey — Public Page
|
// Journey — Public Page
|
||||||
@@ -2393,6 +2401,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'system_notice.v3_thankyou.title': 'A personal note from me',
|
'system_notice.v3_thankyou.title': 'A personal note from me',
|
||||||
'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.',
|
'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.',
|
||||||
|
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Action required: user account conflict',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'The 3.0.14 upgrade detected one or more username or email collisions caused by leading/trailing whitespace in stored accounts. Affected accounts were renamed automatically. Check the server logs for lines starting with **[migration] WHITESPACE COLLISION** to identify which accounts need review.',
|
||||||
|
|
||||||
// System notices — onboarding
|
// System notices — onboarding
|
||||||
'system_notice.welcome_v1.title': 'Welcome to TREK',
|
'system_notice.welcome_v1.title': 'Welcome to TREK',
|
||||||
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
|
'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.',
|
||||||
|
|||||||
@@ -326,6 +326,10 @@ const es: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sesión revocada',
|
'settings.oauth.toast.revoked': 'Sesión revocada',
|
||||||
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
|
'settings.oauth.toast.revokeError': 'Error al revocar la sesión',
|
||||||
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
|
'settings.oauth.toast.rotateError': 'Error al renovar el secreto del cliente',
|
||||||
|
'settings.oauth.modal.machineClient': 'Cliente de máquina (sin inicio de sesión en el navegador)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Usa el grant client_credentials — sin URIs de redirección. El token se emite directamente vía client_id + client_secret y actúa como tú dentro de los alcances seleccionados.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Obtener token: POST /oauth/token con grant_type=client_credentials, client_id y client_secret. Sin navegador, sin token de actualización.',
|
||||||
|
'settings.oauth.badge.machine': 'máquina',
|
||||||
'settings.account': 'Cuenta',
|
'settings.account': 'Cuenta',
|
||||||
'settings.about': 'Acerca de',
|
'settings.about': 'Acerca de',
|
||||||
'settings.about.reportBug': 'Reportar un error',
|
'settings.about.reportBug': 'Reportar un error',
|
||||||
@@ -2084,8 +2088,11 @@ const es: Record<string, string> = {
|
|||||||
'journey.synced.places': 'lugares',
|
'journey.synced.places': 'lugares',
|
||||||
'journey.synced.synced': 'sincronizado',
|
'journey.synced.synced': 'sincronizado',
|
||||||
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
'journey.editor.discardChangesConfirm': 'Tienes cambios sin guardar. ¿Descartarlos?',
|
||||||
|
'journey.editor.uploadFailed': 'Error al subir fotos',
|
||||||
'journey.editor.uploadPhotos': 'Subir fotos',
|
'journey.editor.uploadPhotos': 'Subir fotos',
|
||||||
'journey.editor.uploading': 'Subiendo...',
|
'journey.editor.uploading': 'Subiendo...',
|
||||||
|
'journey.editor.uploadingProgress': 'Subiendo {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} de {total} fotos fallaron — guarda de nuevo para reintentar',
|
||||||
'journey.editor.fromGallery': 'Desde galería',
|
'journey.editor.fromGallery': 'Desde galería',
|
||||||
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
'journey.editor.allPhotosAdded': 'Todas las fotos ya fueron añadidas',
|
||||||
'journey.editor.writeStory': 'Escribe tu historia...',
|
'journey.editor.writeStory': 'Escribe tu historia...',
|
||||||
@@ -2176,6 +2183,7 @@ const es: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': 'Error al eliminar',
|
'journey.settings.failedToDelete': 'Error al eliminar',
|
||||||
'journey.entries.deleteTitle': 'Eliminar entrada',
|
'journey.entries.deleteTitle': 'Eliminar entrada',
|
||||||
'journey.photosUploaded': '{count} fotos subidas',
|
'journey.photosUploaded': '{count} fotos subidas',
|
||||||
|
'journey.photosUploadFailed': 'Algunas fotos no se pudieron subir',
|
||||||
'journey.photosAdded': '{count} fotos añadidas',
|
'journey.photosAdded': '{count} fotos añadidas',
|
||||||
'journey.public.notFound': 'No encontrado',
|
'journey.public.notFound': 'No encontrado',
|
||||||
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
|
'journey.public.notFoundMessage': 'Esta travesía no existe o el enlace ha expirado.',
|
||||||
@@ -2352,6 +2360,9 @@ const es: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
||||||
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Acción requerida: conflicto de cuenta de usuario',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'La actualización 3.0.14 detectó uno o más conflictos de nombre de usuario o correo electrónico causados por espacios en blanco al inicio o al final de los valores almacenados. Las cuentas afectadas se renombraron automáticamente. Revisa los registros del servidor en busca de líneas que empiecen por **[migration] WHITESPACE COLLISION** para identificar qué cuentas necesitan revisión.',
|
||||||
'transport.addTransport': 'Añadir transporte',
|
'transport.addTransport': 'Añadir transporte',
|
||||||
'transport.modalTitle.create': 'Añadir transporte',
|
'transport.modalTitle.create': 'Añadir transporte',
|
||||||
'transport.modalTitle.edit': 'Editar transporte',
|
'transport.modalTitle.edit': 'Editar transporte',
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ const fr: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': 'Session révoquée',
|
'settings.oauth.toast.revoked': 'Session révoquée',
|
||||||
'settings.oauth.toast.revokeError': 'Impossible de révoquer la session',
|
'settings.oauth.toast.revokeError': 'Impossible de révoquer la session',
|
||||||
'settings.oauth.toast.rotateError': 'Impossible de renouveler le secret client',
|
'settings.oauth.toast.rotateError': 'Impossible de renouveler le secret client',
|
||||||
|
'settings.oauth.modal.machineClient': 'Client machine (sans connexion navigateur)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Utilise le grant client_credentials — aucune URI de redirection requise. Le token est émis directement via client_id + client_secret et agit en votre nom dans les portées sélectionnées.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Obtenir un token : POST /oauth/token avec grant_type=client_credentials, client_id et client_secret. Sans navigateur, sans token de rafraîchissement.',
|
||||||
|
'settings.oauth.badge.machine': 'machine',
|
||||||
'settings.account': 'Compte',
|
'settings.account': 'Compte',
|
||||||
'settings.about': 'À propos',
|
'settings.about': 'À propos',
|
||||||
'settings.about.reportBug': 'Signaler un bug',
|
'settings.about.reportBug': 'Signaler un bug',
|
||||||
@@ -2078,8 +2082,11 @@ const fr: Record<string, string> = {
|
|||||||
'journey.synced.places': 'lieux',
|
'journey.synced.places': 'lieux',
|
||||||
'journey.synced.synced': 'synchronisé',
|
'journey.synced.synced': 'synchronisé',
|
||||||
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
'journey.editor.discardChangesConfirm': 'Vous avez des modifications non enregistrées. Les ignorer ?',
|
||||||
|
'journey.editor.uploadFailed': 'Échec du téléversement des photos',
|
||||||
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
'journey.editor.uploadPhotos': 'Téléverser des photos',
|
||||||
'journey.editor.uploading': 'Envoi...',
|
'journey.editor.uploading': 'Envoi...',
|
||||||
|
'journey.editor.uploadingProgress': 'Téléversement {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} sur {total} photos ont échoué — sauvegardez à nouveau pour réessayer',
|
||||||
'journey.editor.fromGallery': 'Depuis la galerie',
|
'journey.editor.fromGallery': 'Depuis la galerie',
|
||||||
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
'journey.editor.allPhotosAdded': 'Toutes les photos ont déjà été ajoutées',
|
||||||
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
'journey.editor.writeStory': 'Écrivez votre histoire...',
|
||||||
@@ -2170,6 +2177,7 @@ const fr: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': 'Échec de la suppression',
|
'journey.settings.failedToDelete': 'Échec de la suppression',
|
||||||
'journey.entries.deleteTitle': "Supprimer l'entrée",
|
'journey.entries.deleteTitle': "Supprimer l'entrée",
|
||||||
'journey.photosUploaded': '{count} photos téléversées',
|
'journey.photosUploaded': '{count} photos téléversées',
|
||||||
|
'journey.photosUploadFailed': "Certaines photos n'ont pas pu être téléversées",
|
||||||
'journey.photosAdded': '{count} photos ajoutées',
|
'journey.photosAdded': '{count} photos ajoutées',
|
||||||
'journey.public.notFound': 'Introuvable',
|
'journey.public.notFound': 'Introuvable',
|
||||||
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
|
'journey.public.notFoundMessage': 'Ce journal n\'existe pas ou le lien a expiré.',
|
||||||
@@ -2346,6 +2354,9 @@ const fr: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
||||||
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': "Action requise : conflit de compte utilisateur",
|
||||||
|
'system_notice.v3014_whitespace_collision.body': "La mise à niveau 3.0.14 a détecté un ou plusieurs conflits de nom d'utilisateur ou d'adresse e-mail causés par des espaces en début ou en fin de valeur dans les comptes enregistrés. Les comptes concernés ont été renommés automatiquement. Consultez les journaux du serveur pour les lignes commençant par **[migration] WHITESPACE COLLISION** afin d'identifier les comptes nécessitant une vérification.",
|
||||||
'transport.addTransport': 'Ajouter un transport',
|
'transport.addTransport': 'Ajouter un transport',
|
||||||
'transport.modalTitle.create': 'Ajouter un transport',
|
'transport.modalTitle.create': 'Ajouter un transport',
|
||||||
'transport.modalTitle.edit': 'Modifier le transport',
|
'transport.modalTitle.edit': 'Modifier le transport',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -280,6 +280,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Munkamenet visszavonva',
|
'settings.oauth.toast.revoked': 'Munkamenet visszavonva',
|
||||||
'settings.oauth.toast.revokeError': 'A munkamenet visszavonása sikertelen',
|
'settings.oauth.toast.revokeError': 'A munkamenet visszavonása sikertelen',
|
||||||
'settings.oauth.toast.rotateError': 'A kliens titok megújítása sikertelen',
|
'settings.oauth.toast.rotateError': 'A kliens titok megújítása sikertelen',
|
||||||
|
'settings.oauth.modal.machineClient': 'Gépi kliens (böngészős bejelentkezés nélkül)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'client_credentials grant használata — nincs szükség átirányítási URI-kra. A token közvetlenül client_id + client_secret segítségével kerül kiállításra, és a kiválasztott hatókörökön belül az Ön nevében jár el.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Token lekérése: POST /oauth/token a grant_type=client_credentials, client_id és client_secret értékekkel. Böngésző és frissítési token nélkül.',
|
||||||
|
'settings.oauth.badge.machine': 'gépi',
|
||||||
'settings.account': 'Fiók',
|
'settings.account': 'Fiók',
|
||||||
'settings.about': 'Névjegy',
|
'settings.about': 'Névjegy',
|
||||||
'settings.about.reportBug': 'Hiba bejelentése',
|
'settings.about.reportBug': 'Hiba bejelentése',
|
||||||
@@ -2079,8 +2083,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'helyszín',
|
'journey.synced.places': 'helyszín',
|
||||||
'journey.synced.synced': 'szinkronizálva',
|
'journey.synced.synced': 'szinkronizálva',
|
||||||
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
'journey.editor.discardChangesConfirm': 'Mentetlen módosításaid vannak. Elveted?',
|
||||||
|
'journey.editor.uploadFailed': 'A fotók feltöltése sikertelen',
|
||||||
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
'journey.editor.uploadPhotos': 'Fotók feltöltése',
|
||||||
'journey.editor.uploading': 'Feltöltés...',
|
'journey.editor.uploading': 'Feltöltés...',
|
||||||
|
'journey.editor.uploadingProgress': 'Feltöltés {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} / {total} fotó sikertelen — mentsd el újra a próbálkozáshoz',
|
||||||
'journey.editor.fromGallery': 'Galériából',
|
'journey.editor.fromGallery': 'Galériából',
|
||||||
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
|
'journey.editor.allPhotosAdded': 'Minden fotó már hozzáadva',
|
||||||
'journey.editor.writeStory': 'Írd meg a történeted...',
|
'journey.editor.writeStory': 'Írd meg a történeted...',
|
||||||
@@ -2171,6 +2178,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Törlés sikertelen',
|
'journey.settings.failedToDelete': 'Törlés sikertelen',
|
||||||
'journey.entries.deleteTitle': 'Bejegyzés törlése',
|
'journey.entries.deleteTitle': 'Bejegyzés törlése',
|
||||||
'journey.photosUploaded': '{count} fotó feltöltve',
|
'journey.photosUploaded': '{count} fotó feltöltve',
|
||||||
|
'journey.photosUploadFailed': 'Néhány fotót nem sikerült feltölteni',
|
||||||
'journey.photosAdded': '{count} fotó hozzáadva',
|
'journey.photosAdded': '{count} fotó hozzáadva',
|
||||||
'journey.public.notFound': 'Nem található',
|
'journey.public.notFound': 'Nem található',
|
||||||
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
|
'journey.public.notFoundMessage': 'Ez az útinapló nem létezik vagy a link lejárt.',
|
||||||
@@ -2347,6 +2355,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
||||||
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Szükséges beavatkozás: felhasználói fiókütközés',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'A 3.0.14-es frissítés egy vagy több felhasználónév- vagy e-mail-ütközést észlelt, amelyeket a tárolt értékek elején vagy végén lévő szóközök okoztak. Az érintett fiókok automatikusan át lettek nevezve. Ellenőrizze a szervernaplókat a **[migration] WHITESPACE COLLISION** kezdetű soroknál a felülvizsgálatot igénylő fiókok azonosításához.',
|
||||||
'transport.addTransport': 'Közlekedés hozzáadása',
|
'transport.addTransport': 'Közlekedés hozzáadása',
|
||||||
'transport.modalTitle.create': 'Közlekedés hozzáadása',
|
'transport.modalTitle.create': 'Közlekedés hozzáadása',
|
||||||
'transport.modalTitle.edit': 'Közlekedés szerkesztése',
|
'transport.modalTitle.edit': 'Közlekedés szerkesztése',
|
||||||
|
|||||||
@@ -387,6 +387,10 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sesi dicabut',
|
'settings.oauth.toast.revoked': 'Sesi dicabut',
|
||||||
'settings.oauth.toast.revokeError': 'Gagal mencabut sesi',
|
'settings.oauth.toast.revokeError': 'Gagal mencabut sesi',
|
||||||
'settings.oauth.toast.rotateError': 'Gagal memutar ulang client secret',
|
'settings.oauth.toast.rotateError': 'Gagal memutar ulang client secret',
|
||||||
|
'settings.oauth.modal.machineClient': 'Klien mesin (tanpa login browser)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Menggunakan grant client_credentials — tidak perlu URI pengalihan. Token diterbitkan langsung melalui client_id + client_secret dan bertindak sebagai Anda dalam cakupan yang dipilih.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Dapatkan token: POST /oauth/token dengan grant_type=client_credentials, client_id, dan client_secret. Tanpa browser, tanpa refresh token.',
|
||||||
|
'settings.oauth.badge.machine': 'mesin',
|
||||||
'settings.account': 'Akun',
|
'settings.account': 'Akun',
|
||||||
'settings.about': 'Tentang',
|
'settings.about': 'Tentang',
|
||||||
'settings.about.reportBug': 'Laporkan Bug',
|
'settings.about.reportBug': 'Laporkan Bug',
|
||||||
@@ -2094,8 +2098,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Journey Entry Editor
|
// Journey Entry Editor
|
||||||
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
'journey.editor.discardChangesConfirm': 'Anda memiliki perubahan yang belum disimpan. Buang?',
|
||||||
|
'journey.editor.uploadFailed': 'Gagal mengunggah foto',
|
||||||
'journey.editor.uploadPhotos': 'Unggah foto',
|
'journey.editor.uploadPhotos': 'Unggah foto',
|
||||||
'journey.editor.uploading': 'Mengunggah...',
|
'journey.editor.uploading': 'Mengunggah...',
|
||||||
|
'journey.editor.uploadingProgress': 'Mengunggah {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} dari {total} foto gagal — simpan lagi untuk mencoba ulang',
|
||||||
'journey.editor.fromGallery': 'Dari Galeri',
|
'journey.editor.fromGallery': 'Dari Galeri',
|
||||||
'journey.editor.allPhotosAdded': 'Semua foto sudah ditambahkan',
|
'journey.editor.allPhotosAdded': 'Semua foto sudah ditambahkan',
|
||||||
'journey.editor.writeStory': 'Tulis kisahmu...',
|
'journey.editor.writeStory': 'Tulis kisahmu...',
|
||||||
@@ -2198,6 +2205,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Gagal menghapus',
|
'journey.settings.failedToDelete': 'Gagal menghapus',
|
||||||
'journey.entries.deleteTitle': 'Hapus Entri',
|
'journey.entries.deleteTitle': 'Hapus Entri',
|
||||||
'journey.photosUploaded': '{count} foto diunggah',
|
'journey.photosUploaded': '{count} foto diunggah',
|
||||||
|
'journey.photosUploadFailed': 'Beberapa foto gagal diunggah',
|
||||||
'journey.photosAdded': '{count} foto ditambahkan',
|
'journey.photosAdded': '{count} foto ditambahkan',
|
||||||
|
|
||||||
// Journey — Public Page
|
// Journey — Public Page
|
||||||
@@ -2388,6 +2396,9 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
||||||
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Tindakan diperlukan: konflik akun pengguna',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'Pembaruan 3.0.14 mendeteksi satu atau lebih konflik nama pengguna atau email yang disebabkan oleh spasi di awal atau akhir nilai yang tersimpan. Akun yang terpengaruh telah diganti nama secara otomatis. Periksa log server untuk baris yang dimulai dengan **[migration] WHITESPACE COLLISION** guna mengidentifikasi akun mana yang perlu ditinjau.',
|
||||||
'transport.addTransport': 'Tambah transportasi',
|
'transport.addTransport': 'Tambah transportasi',
|
||||||
'transport.modalTitle.create': 'Tambah transportasi',
|
'transport.modalTitle.create': 'Tambah transportasi',
|
||||||
'transport.modalTitle.edit': 'Edit transportasi',
|
'transport.modalTitle.edit': 'Edit transportasi',
|
||||||
|
|||||||
@@ -280,6 +280,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sessione revocata',
|
'settings.oauth.toast.revoked': 'Sessione revocata',
|
||||||
'settings.oauth.toast.revokeError': 'Impossibile revocare la sessione',
|
'settings.oauth.toast.revokeError': 'Impossibile revocare la sessione',
|
||||||
'settings.oauth.toast.rotateError': 'Impossibile rinnovare il segreto client',
|
'settings.oauth.toast.rotateError': 'Impossibile rinnovare il segreto client',
|
||||||
|
'settings.oauth.modal.machineClient': 'Client macchina (senza login nel browser)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Usa il grant client_credentials — nessun URI di reindirizzamento necessario. Il token viene emesso direttamente tramite client_id + client_secret e agisce come te negli ambiti selezionati.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Ottieni token: POST /oauth/token con grant_type=client_credentials, client_id e client_secret. Senza browser, senza token di aggiornamento.',
|
||||||
|
'settings.oauth.badge.machine': 'macchina',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.about': 'Informazioni',
|
'settings.about': 'Informazioni',
|
||||||
'settings.about.reportBug': 'Segnala un bug',
|
'settings.about.reportBug': 'Segnala un bug',
|
||||||
@@ -2079,8 +2083,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'luoghi',
|
'journey.synced.places': 'luoghi',
|
||||||
'journey.synced.synced': 'sincronizzato',
|
'journey.synced.synced': 'sincronizzato',
|
||||||
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
'journey.editor.discardChangesConfirm': 'Hai modifiche non salvate. Vuoi scartarle?',
|
||||||
|
'journey.editor.uploadFailed': 'Caricamento foto non riuscito',
|
||||||
'journey.editor.uploadPhotos': 'Carica foto',
|
'journey.editor.uploadPhotos': 'Carica foto',
|
||||||
'journey.editor.uploading': 'Caricamento...',
|
'journey.editor.uploading': 'Caricamento...',
|
||||||
|
'journey.editor.uploadingProgress': 'Caricamento {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} di {total} foto non riuscite — salva di nuovo per riprovare',
|
||||||
'journey.editor.fromGallery': 'Dalla galleria',
|
'journey.editor.fromGallery': 'Dalla galleria',
|
||||||
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
'journey.editor.allPhotosAdded': 'Tutte le foto sono già state aggiunte',
|
||||||
'journey.editor.writeStory': 'Scrivi la tua storia...',
|
'journey.editor.writeStory': 'Scrivi la tua storia...',
|
||||||
@@ -2171,6 +2178,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Eliminazione non riuscita',
|
'journey.settings.failedToDelete': 'Eliminazione non riuscita',
|
||||||
'journey.entries.deleteTitle': 'Elimina voce',
|
'journey.entries.deleteTitle': 'Elimina voce',
|
||||||
'journey.photosUploaded': '{count} foto caricate',
|
'journey.photosUploaded': '{count} foto caricate',
|
||||||
|
'journey.photosUploadFailed': 'Alcune foto non sono state caricate',
|
||||||
'journey.photosAdded': '{count} foto aggiunte',
|
'journey.photosAdded': '{count} foto aggiunte',
|
||||||
'journey.public.notFound': 'Non trovato',
|
'journey.public.notFound': 'Non trovato',
|
||||||
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
|
'journey.public.notFoundMessage': 'Questo diario non esiste o il link è scaduto.',
|
||||||
@@ -2347,6 +2355,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
||||||
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Azione richiesta: conflitto di account utente',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': "L'aggiornamento 3.0.14 ha rilevato uno o più conflitti di nome utente o e-mail causati da spazi iniziali o finali nei valori memorizzati. Gli account interessati sono stati rinominati automaticamente. Controlla i log del server per le righe che iniziano con **[migration] WHITESPACE COLLISION** per identificare quali account richiedono revisione.",
|
||||||
'transport.addTransport': 'Aggiungi trasporto',
|
'transport.addTransport': 'Aggiungi trasporto',
|
||||||
'transport.modalTitle.create': 'Aggiungi trasporto',
|
'transport.modalTitle.create': 'Aggiungi trasporto',
|
||||||
'transport.modalTitle.edit': 'Modifica trasporto',
|
'transport.modalTitle.edit': 'Modifica trasporto',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -325,6 +325,10 @@ const nl: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sessie ingetrokken',
|
'settings.oauth.toast.revoked': 'Sessie ingetrokken',
|
||||||
'settings.oauth.toast.revokeError': 'Sessie kon niet worden ingetrokken',
|
'settings.oauth.toast.revokeError': 'Sessie kon niet worden ingetrokken',
|
||||||
'settings.oauth.toast.rotateError': 'Clientgeheim kon niet worden vernieuwd',
|
'settings.oauth.toast.rotateError': 'Clientgeheim kon niet worden vernieuwd',
|
||||||
|
'settings.oauth.modal.machineClient': 'Machineclient (zonder browserinlog)',
|
||||||
|
'settings.oauth.modal.machineClientHint': "Gebruikt de client_credentials grant — geen redirect-URI's nodig. Het token wordt direct verstrekt via client_id + client_secret en handelt namens jou binnen de geselecteerde scopes.",
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Token ophalen: POST /oauth/token met grant_type=client_credentials, client_id en client_secret. Geen browser, geen vernieuwingstoken.',
|
||||||
|
'settings.oauth.badge.machine': 'machine',
|
||||||
'settings.account': 'Account',
|
'settings.account': 'Account',
|
||||||
'settings.about': 'Over',
|
'settings.about': 'Over',
|
||||||
'settings.about.reportBug': 'Bug melden',
|
'settings.about.reportBug': 'Bug melden',
|
||||||
@@ -2078,8 +2082,11 @@ const nl: Record<string, string> = {
|
|||||||
'journey.synced.places': 'plaatsen',
|
'journey.synced.places': 'plaatsen',
|
||||||
'journey.synced.synced': 'gesynchroniseerd',
|
'journey.synced.synced': 'gesynchroniseerd',
|
||||||
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
'journey.editor.discardChangesConfirm': 'Je hebt niet-opgeslagen wijzigingen. Verwerpen?',
|
||||||
|
'journey.editor.uploadFailed': 'Foto uploaden mislukt',
|
||||||
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
'journey.editor.uploadPhotos': 'Foto\'s uploaden',
|
||||||
'journey.editor.uploading': 'Uploaden...',
|
'journey.editor.uploading': 'Uploaden...',
|
||||||
|
'journey.editor.uploadingProgress': 'Uploaden {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} van {total} foto\'s mislukt — sla opnieuw op om het opnieuw te proberen',
|
||||||
'journey.editor.fromGallery': 'Uit galerij',
|
'journey.editor.fromGallery': 'Uit galerij',
|
||||||
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
'journey.editor.allPhotosAdded': 'Alle foto\'s al toegevoegd',
|
||||||
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
'journey.editor.writeStory': 'Schrijf je verhaal...',
|
||||||
@@ -2170,6 +2177,7 @@ const nl: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': 'Verwijderen mislukt',
|
'journey.settings.failedToDelete': 'Verwijderen mislukt',
|
||||||
'journey.entries.deleteTitle': 'Vermelding verwijderen',
|
'journey.entries.deleteTitle': 'Vermelding verwijderen',
|
||||||
'journey.photosUploaded': "{count} foto's geüpload",
|
'journey.photosUploaded': "{count} foto's geüpload",
|
||||||
|
'journey.photosUploadFailed': "Sommige foto's konden niet worden geüpload",
|
||||||
'journey.photosAdded': "{count} foto's toegevoegd",
|
'journey.photosAdded': "{count} foto's toegevoegd",
|
||||||
'journey.public.notFound': 'Niet gevonden',
|
'journey.public.notFound': 'Niet gevonden',
|
||||||
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
|
'journey.public.notFoundMessage': 'Dit reisverslag bestaat niet of de link is verlopen.',
|
||||||
@@ -2346,6 +2354,9 @@ const nl: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
||||||
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Actie vereist: gebruikersaccountconflict',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'De 3.0.14-upgrade heeft één of meer conflicten in gebruikersnaam of e-mailadres gedetecteerd, veroorzaakt door spaties aan het begin of einde van opgeslagen waarden. Getroffen accounts zijn automatisch hernoemd. Controleer de serverlogboeken op regels die beginnen met **[migration] WHITESPACE COLLISION** om te achterhalen welke accounts moeten worden beoordeeld.',
|
||||||
'transport.addTransport': 'Vervoer toevoegen',
|
'transport.addTransport': 'Vervoer toevoegen',
|
||||||
'transport.modalTitle.create': 'Vervoer toevoegen',
|
'transport.modalTitle.create': 'Vervoer toevoegen',
|
||||||
'transport.modalTitle.edit': 'Vervoer bewerken',
|
'transport.modalTitle.edit': 'Vervoer bewerken',
|
||||||
|
|||||||
@@ -295,6 +295,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'settings.oauth.toast.revoked': 'Sesja unieważniona',
|
'settings.oauth.toast.revoked': 'Sesja unieważniona',
|
||||||
'settings.oauth.toast.revokeError': 'Nie udało się unieważnić sesji',
|
'settings.oauth.toast.revokeError': 'Nie udało się unieważnić sesji',
|
||||||
'settings.oauth.toast.rotateError': 'Nie udało się odnowić sekretu klienta',
|
'settings.oauth.toast.rotateError': 'Nie udało się odnowić sekretu klienta',
|
||||||
|
'settings.oauth.modal.machineClient': 'Klient maszynowy (bez logowania przez przeglądarkę)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Używa grantu client_credentials — nie są potrzebne URI przekierowania. Token jest wystawiany bezpośrednio przez client_id + client_secret i działa w Twoim imieniu w ramach wybranych zakresów.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Pobierz token: POST /oauth/token z grant_type=client_credentials, client_id i client_secret. Bez przeglądarki, bez tokenu odświeżania.',
|
||||||
|
'settings.oauth.badge.machine': 'maszynowy',
|
||||||
'settings.account': 'Konto',
|
'settings.account': 'Konto',
|
||||||
'settings.about': 'O aplikacji',
|
'settings.about': 'O aplikacji',
|
||||||
'settings.about.reportBug': 'Zgłoś błąd',
|
'settings.about.reportBug': 'Zgłoś błąd',
|
||||||
@@ -2071,8 +2075,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.synced.places': 'miejsca',
|
'journey.synced.places': 'miejsca',
|
||||||
'journey.synced.synced': 'zsynchronizowane',
|
'journey.synced.synced': 'zsynchronizowane',
|
||||||
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
'journey.editor.discardChangesConfirm': 'Masz niezapisane zmiany. Odrzucić?',
|
||||||
|
'journey.editor.uploadFailed': 'Przesyłanie zdjęć nie powiodło się',
|
||||||
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
'journey.editor.uploadPhotos': 'Prześlij zdjęcia',
|
||||||
'journey.editor.uploading': 'Przesyłanie...',
|
'journey.editor.uploading': 'Przesyłanie...',
|
||||||
|
'journey.editor.uploadingProgress': 'Przesyłanie {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} z {total} zdjęć nie powiodło się — zapisz ponownie, aby spróbować',
|
||||||
'journey.editor.fromGallery': 'Z galerii',
|
'journey.editor.fromGallery': 'Z galerii',
|
||||||
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
'journey.editor.allPhotosAdded': 'Wszystkie zdjęcia już dodane',
|
||||||
'journey.editor.writeStory': 'Napisz swoją historię...',
|
'journey.editor.writeStory': 'Napisz swoją historię...',
|
||||||
@@ -2163,6 +2170,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'journey.settings.failedToDelete': 'Nie udało się usunąć',
|
'journey.settings.failedToDelete': 'Nie udało się usunąć',
|
||||||
'journey.entries.deleteTitle': 'Usuń wpis',
|
'journey.entries.deleteTitle': 'Usuń wpis',
|
||||||
'journey.photosUploaded': '{count} zdjęć przesłanych',
|
'journey.photosUploaded': '{count} zdjęć przesłanych',
|
||||||
|
'journey.photosUploadFailed': 'Nie udało się przesłać niektórych zdjęć',
|
||||||
'journey.photosAdded': '{count} zdjęć dodanych',
|
'journey.photosAdded': '{count} zdjęć dodanych',
|
||||||
'journey.public.notFound': 'Nie znaleziono',
|
'journey.public.notFound': 'Nie znaleziono',
|
||||||
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
|
'journey.public.notFoundMessage': 'Ten dziennik podróży nie istnieje lub link wygasł.',
|
||||||
@@ -2339,6 +2347,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
||||||
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Wymagane działanie: konflikt konta użytkownika',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'Aktualizacja 3.0.14 wykryła jeden lub więcej konfliktów nazwy użytkownika lub adresu e-mail spowodowanych spacjami na początku lub końcu przechowywanych wartości. Dotknięte konta zostały automatycznie przemianowane. Sprawdź logi serwera pod kątem wierszy zaczynających się od **[migration] WHITESPACE COLLISION**, aby zidentyfikować konta wymagające przeglądu.',
|
||||||
'transport.addTransport': 'Dodaj transport',
|
'transport.addTransport': 'Dodaj transport',
|
||||||
'transport.modalTitle.create': 'Dodaj transport',
|
'transport.modalTitle.create': 'Dodaj transport',
|
||||||
'transport.modalTitle.edit': 'Edytuj transport',
|
'transport.modalTitle.edit': 'Edytuj transport',
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ const ru: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': 'Сессия отозвана',
|
'settings.oauth.toast.revoked': 'Сессия отозвана',
|
||||||
'settings.oauth.toast.revokeError': 'Не удалось отозвать сессию',
|
'settings.oauth.toast.revokeError': 'Не удалось отозвать сессию',
|
||||||
'settings.oauth.toast.rotateError': 'Не удалось обновить секрет клиента',
|
'settings.oauth.toast.rotateError': 'Не удалось обновить секрет клиента',
|
||||||
|
'settings.oauth.modal.machineClient': 'Машинный клиент (без входа через браузер)',
|
||||||
|
'settings.oauth.modal.machineClientHint': 'Использует грант client_credentials — URI перенаправления не требуются. Токен выдаётся напрямую через client_id + client_secret и действует от вашего имени в пределах выбранных областей.',
|
||||||
|
'settings.oauth.modal.machineClientUsage': 'Получить токен: POST /oauth/token с grant_type=client_credentials, client_id и client_secret. Без браузера, без токена обновления.',
|
||||||
|
'settings.oauth.badge.machine': 'машинный',
|
||||||
'settings.account': 'Аккаунт',
|
'settings.account': 'Аккаунт',
|
||||||
'settings.about': 'О приложении',
|
'settings.about': 'О приложении',
|
||||||
'settings.about.reportBug': 'Сообщить об ошибке',
|
'settings.about.reportBug': 'Сообщить об ошибке',
|
||||||
@@ -2078,8 +2082,11 @@ const ru: Record<string, string> = {
|
|||||||
'journey.synced.places': 'мест',
|
'journey.synced.places': 'мест',
|
||||||
'journey.synced.synced': 'синхронизировано',
|
'journey.synced.synced': 'синхронизировано',
|
||||||
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
'journey.editor.discardChangesConfirm': 'У вас есть несохранённые изменения. Отменить?',
|
||||||
|
'journey.editor.uploadFailed': 'Не удалось загрузить фото',
|
||||||
'journey.editor.uploadPhotos': 'Загрузить фото',
|
'journey.editor.uploadPhotos': 'Загрузить фото',
|
||||||
'journey.editor.uploading': 'Загрузка...',
|
'journey.editor.uploading': 'Загрузка...',
|
||||||
|
'journey.editor.uploadingProgress': 'Загрузка {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{failed} из {total} фото не удалось загрузить — сохраните снова для повтора',
|
||||||
'journey.editor.fromGallery': 'Из галереи',
|
'journey.editor.fromGallery': 'Из галереи',
|
||||||
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
'journey.editor.allPhotosAdded': 'Все фото уже добавлены',
|
||||||
'journey.editor.writeStory': 'Напишите свою историю...',
|
'journey.editor.writeStory': 'Напишите свою историю...',
|
||||||
@@ -2170,6 +2177,7 @@ const ru: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': 'Не удалось удалить',
|
'journey.settings.failedToDelete': 'Не удалось удалить',
|
||||||
'journey.entries.deleteTitle': 'Удалить запись',
|
'journey.entries.deleteTitle': 'Удалить запись',
|
||||||
'journey.photosUploaded': '{count} фото загружено',
|
'journey.photosUploaded': '{count} фото загружено',
|
||||||
|
'journey.photosUploadFailed': 'Некоторые фото не удалось загрузить',
|
||||||
'journey.photosAdded': '{count} фото добавлено',
|
'journey.photosAdded': '{count} фото добавлено',
|
||||||
'journey.public.notFound': 'Не найдено',
|
'journey.public.notFound': 'Не найдено',
|
||||||
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
|
'journey.public.notFoundMessage': 'Это путешествие не существует или ссылка устарела.',
|
||||||
@@ -2346,6 +2354,9 @@ const ru: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
||||||
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
|
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': 'Требуется действие: конфликт учётных записей',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': 'Обновление 3.0.14 обнаружило один или несколько конфликтов имён пользователей или адресов электронной почты, вызванных ведущими или завершающими пробелами в сохранённых значениях. Затронутые учётные записи были автоматически переименованы. Проверьте логи сервера на строки, начинающиеся с **[migration] WHITESPACE COLLISION**, чтобы определить учётные записи, требующие проверки.',
|
||||||
'transport.addTransport': 'Добавить транспорт',
|
'transport.addTransport': 'Добавить транспорт',
|
||||||
'transport.modalTitle.create': 'Добавить транспорт',
|
'transport.modalTitle.create': 'Добавить транспорт',
|
||||||
'transport.modalTitle.edit': 'Изменить транспорт',
|
'transport.modalTitle.edit': 'Изменить транспорт',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -325,6 +325,10 @@ const zh: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': '会话已撤销',
|
'settings.oauth.toast.revoked': '会话已撤销',
|
||||||
'settings.oauth.toast.revokeError': '撤销会话失败',
|
'settings.oauth.toast.revokeError': '撤销会话失败',
|
||||||
'settings.oauth.toast.rotateError': '轮换客户端密钥失败',
|
'settings.oauth.toast.rotateError': '轮换客户端密钥失败',
|
||||||
|
'settings.oauth.modal.machineClient': '机器客户端(无需浏览器登录)',
|
||||||
|
'settings.oauth.modal.machineClientHint': '使用 client_credentials 授权——无需重定向 URI。令牌通过 client_id + client_secret 直接颁发,并在所选范围内以您的身份运行。',
|
||||||
|
'settings.oauth.modal.machineClientUsage': '获取令牌:向 /oauth/token 发送 POST 请求,携带 grant_type=client_credentials、client_id 和 client_secret。无需浏览器,无刷新令牌。',
|
||||||
|
'settings.oauth.badge.machine': '机器',
|
||||||
'settings.account': '账户',
|
'settings.account': '账户',
|
||||||
'settings.about': '关于',
|
'settings.about': '关于',
|
||||||
'settings.about.reportBug': '报告错误',
|
'settings.about.reportBug': '报告错误',
|
||||||
@@ -2078,8 +2082,11 @@ const zh: Record<string, string> = {
|
|||||||
'journey.synced.places': '个地点',
|
'journey.synced.places': '个地点',
|
||||||
'journey.synced.synced': '已同步',
|
'journey.synced.synced': '已同步',
|
||||||
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
'journey.editor.discardChangesConfirm': '您有未保存的更改。要放弃吗?',
|
||||||
|
'journey.editor.uploadFailed': '照片上传失败',
|
||||||
'journey.editor.uploadPhotos': '上传照片',
|
'journey.editor.uploadPhotos': '上传照片',
|
||||||
'journey.editor.uploading': '上传中...',
|
'journey.editor.uploading': '上传中...',
|
||||||
|
'journey.editor.uploadingProgress': '上传中 {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{total} 张中有 {failed} 张上传失败 — 再次保存以重试',
|
||||||
'journey.editor.fromGallery': '从相册',
|
'journey.editor.fromGallery': '从相册',
|
||||||
'journey.editor.allPhotosAdded': '所有照片已添加',
|
'journey.editor.allPhotosAdded': '所有照片已添加',
|
||||||
'journey.editor.writeStory': '写下你的故事...',
|
'journey.editor.writeStory': '写下你的故事...',
|
||||||
@@ -2170,6 +2177,7 @@ const zh: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': '删除失败',
|
'journey.settings.failedToDelete': '删除失败',
|
||||||
'journey.entries.deleteTitle': '删除条目',
|
'journey.entries.deleteTitle': '删除条目',
|
||||||
'journey.photosUploaded': '{count} 张照片已上传',
|
'journey.photosUploaded': '{count} 张照片已上传',
|
||||||
|
'journey.photosUploadFailed': '部分照片上传失败',
|
||||||
'journey.photosAdded': '{count} 张照片已添加',
|
'journey.photosAdded': '{count} 张照片已添加',
|
||||||
'journey.public.notFound': '未找到',
|
'journey.public.notFound': '未找到',
|
||||||
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
|
'journey.public.notFoundMessage': '此旅程不存在或链接已过期。',
|
||||||
@@ -2346,6 +2354,9 @@ const zh: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
||||||
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
|
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': '需要操作:用户账户冲突',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': '3.0.14 版本升级检测到一个或多个由存储账户中首尾空白字符引发的用户名或邮箱冲突。受影响的账户已自动重命名。请检查服务器日志中以 **[migration] WHITESPACE COLLISION** 开头的行,以确认哪些账户需要审查。',
|
||||||
'transport.addTransport': '添加交通',
|
'transport.addTransport': '添加交通',
|
||||||
'transport.modalTitle.create': '添加交通',
|
'transport.modalTitle.create': '添加交通',
|
||||||
'transport.modalTitle.edit': '编辑交通',
|
'transport.modalTitle.edit': '编辑交通',
|
||||||
|
|||||||
@@ -384,6 +384,10 @@ const zhTw: Record<string, string> = {
|
|||||||
'settings.oauth.toast.revoked': '工作階段已撤銷',
|
'settings.oauth.toast.revoked': '工作階段已撤銷',
|
||||||
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
|
'settings.oauth.toast.revokeError': '撤銷工作階段失敗',
|
||||||
'settings.oauth.toast.rotateError': '輪換客戶端密鑰失敗',
|
'settings.oauth.toast.rotateError': '輪換客戶端密鑰失敗',
|
||||||
|
'settings.oauth.modal.machineClient': '機器客戶端(無需瀏覽器登入)',
|
||||||
|
'settings.oauth.modal.machineClientHint': '使用 client_credentials 授權——無需重新導向 URI。令牌透過 client_id + client_secret 直接簽發,並在所選範圍內以您的身份運行。',
|
||||||
|
'settings.oauth.modal.machineClientUsage': '取得令牌:向 /oauth/token 發送 POST 請求,攜帶 grant_type=client_credentials、client_id 和 client_secret。無需瀏覽器,無重整令牌。',
|
||||||
|
'settings.oauth.badge.machine': '機器',
|
||||||
'settings.account': '賬戶',
|
'settings.account': '賬戶',
|
||||||
'settings.about': '關於',
|
'settings.about': '關於',
|
||||||
'settings.about.reportBug': '回報錯誤',
|
'settings.about.reportBug': '回報錯誤',
|
||||||
@@ -2036,8 +2040,11 @@ const zhTw: Record<string, string> = {
|
|||||||
'journey.synced.places': '個地點',
|
'journey.synced.places': '個地點',
|
||||||
'journey.synced.synced': '已同步',
|
'journey.synced.synced': '已同步',
|
||||||
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
'journey.editor.discardChangesConfirm': '您有未儲存的變更。要放棄嗎?',
|
||||||
|
'journey.editor.uploadFailed': '照片上傳失敗',
|
||||||
'journey.editor.uploadPhotos': '上傳照片',
|
'journey.editor.uploadPhotos': '上傳照片',
|
||||||
'journey.editor.uploading': '上傳中...',
|
'journey.editor.uploading': '上傳中...',
|
||||||
|
'journey.editor.uploadingProgress': '上傳中 {done}/{total}…',
|
||||||
|
'journey.editor.uploadPartialFailed': '{total} 張中有 {failed} 張上傳失敗 — 再次儲存以重試',
|
||||||
'journey.editor.fromGallery': '從相簿',
|
'journey.editor.fromGallery': '從相簿',
|
||||||
'journey.editor.allPhotosAdded': '所有照片已新增',
|
'journey.editor.allPhotosAdded': '所有照片已新增',
|
||||||
'journey.editor.writeStory': '寫下你的故事...',
|
'journey.editor.writeStory': '寫下你的故事...',
|
||||||
@@ -2128,6 +2135,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'journey.settings.failedToDelete': '刪除失敗',
|
'journey.settings.failedToDelete': '刪除失敗',
|
||||||
'journey.entries.deleteTitle': '刪除條目',
|
'journey.entries.deleteTitle': '刪除條目',
|
||||||
'journey.photosUploaded': '{count} 張照片已上傳',
|
'journey.photosUploaded': '{count} 張照片已上傳',
|
||||||
|
'journey.photosUploadFailed': '部分照片上傳失敗',
|
||||||
'journey.photosAdded': '{count} 張照片已新增',
|
'journey.photosAdded': '{count} 張照片已新增',
|
||||||
'journey.public.notFound': '未找到',
|
'journey.public.notFound': '未找到',
|
||||||
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
|
'journey.public.notFoundMessage': '此旅程不存在或連結已過期。',
|
||||||
@@ -2347,6 +2355,9 @@ const zhTw: Record<string, string> = {
|
|||||||
// System notices — personal thank you
|
// System notices — personal thank you
|
||||||
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
||||||
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
|
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
|
||||||
|
// System notices — 3.0.14
|
||||||
|
'system_notice.v3014_whitespace_collision.title': '需要操作:使用者帳戶衝突',
|
||||||
|
'system_notice.v3014_whitespace_collision.body': '3.0.14 版本升級偵測到一個或多個由儲存帳戶中前後空白字元引發的使用者名稱或電子郵件衝突。受影響的帳戶已自動重新命名。請檢查伺服器日誌中以 **[migration] WHITESPACE COLLISION** 開頭的行,以確認哪些帳戶需要審查。',
|
||||||
'transport.addTransport': '新增交通',
|
'transport.addTransport': '新增交通',
|
||||||
'transport.modalTitle.create': '新增交通',
|
'transport.modalTitle.create': '新增交通',
|
||||||
'transport.modalTitle.edit': '編輯交通',
|
'transport.modalTitle.edit': '編輯交通',
|
||||||
|
|||||||
@@ -812,3 +812,21 @@ img[alt="TREK"] {
|
|||||||
.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>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { formatLocationName } from '../utils/formatters'
|
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'
|
||||||
@@ -29,6 +31,7 @@ import MobileEntryView from '../components/Journey/MobileEntryView'
|
|||||||
import { useIsMobile } from '../hooks/useIsMobile'
|
import { useIsMobile } from '../hooks/useIsMobile'
|
||||||
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip, JourneyDetail } from '../store/journeyStore'
|
import type { JourneyEntry, JourneyPhoto, 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%)',
|
||||||
@@ -746,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)
|
||||||
@@ -985,7 +988,8 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
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
|
||||||
@@ -1025,17 +1029,22 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
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 {
|
||||||
const formData = new FormData()
|
const normalized = await normalizeImageFiles(files)
|
||||||
for (const f of files) formData.append('photos', f)
|
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, {
|
||||||
await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }),
|
||||||
toast.success(t('journey.photosUploaded', { count: files.length }))
|
})
|
||||||
|
if (failed.length > 0) {
|
||||||
|
toast.error(t('journey.editor.uploadPartialFailed', { failed: String(failed.length), total: String(normalized.length) }))
|
||||||
|
} else {
|
||||||
|
toast.success(t('journey.photosUploaded', { count: String(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 = ''
|
||||||
}
|
}
|
||||||
@@ -1080,7 +1089,7 @@ function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick,
|
|||||||
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')}</>
|
||||||
)}
|
)}
|
||||||
@@ -1769,7 +1778,7 @@ function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, on
|
|||||||
: t('journey.picker.newGallery')
|
: t('journey.picker.newGallery')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
|
<div className="fixed inset-0 z-[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-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }} onClick={e => e.stopPropagation()}>
|
<div className="bg-white dark:bg-zinc-900 rounded-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 */}
|
||||||
@@ -2169,10 +2178,11 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
|||||||
galleryPhotos: GalleryPhoto[]
|
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 || '')
|
||||||
@@ -2191,7 +2201,7 @@ 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 | GalleryPhoto)[]>(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[]>([])
|
||||||
@@ -2244,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) {
|
||||||
@@ -2265,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 (
|
||||||
@@ -2300,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')}</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
|
||||||
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
|
||||||
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
|
setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo');
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
|
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,21 +60,21 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
describe('FE-PAGE-LOGIN-023: OIDC code exchange navigates to sessionStorage redirect', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/auth/oidc/exchange', () =>
|
http.get('/api/auth/oidc/exchange', () =>
|
||||||
HttpResponse.json({ token: 'mock-oidc-token' })
|
HttpResponse.json({ token: 'mock-oidc-token' })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
|
||||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
|
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz');
|
||||||
setSearch('?oidc_code=testcode123');
|
setSearch('?oidc_code=testcode123');
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockNavigate).toHaveBeenCalledWith(
|
expect(mockNavigate).toHaveBeenCalledWith(
|
||||||
'/oauth/authorize?client_id=foo&state=xyz',
|
'/oauth/consent?client_id=foo&state=xyz',
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
|
||||||
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
|
||||||
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
|
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo');
|
||||||
setSearch('?oidc_error=token_failed');
|
setSearch('?oidc_error=token_failed');
|
||||||
render(<LoginPage />);
|
render(<LoginPage />);
|
||||||
|
|
||||||
@@ -102,4 +102,4 @@ describe('LoginPage — OIDC redirect preservation', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -117,15 +117,29 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
const CONFIG_CACHE_KEY = 'trek_app_config_cache'
|
||||||
if (config) {
|
authApi.getAppConfig?.()
|
||||||
setAppConfig(config)
|
.then((config: AppConfig) => {
|
||||||
if (!config.has_users) setMode('register')
|
try { localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config)) } catch { /* ignore quota errors */ }
|
||||||
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
return { config, fromCache: false }
|
||||||
window.location.href = '/api/auth/oidc/login'
|
})
|
||||||
|
.catch(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(CONFIG_CACHE_KEY)
|
||||||
|
return raw ? { config: JSON.parse(raw) as AppConfig, fromCache: true } : { config: null as AppConfig | null, fromCache: false }
|
||||||
|
} catch { return { config: null as AppConfig | null, fromCache: false } }
|
||||||
|
})
|
||||||
|
.then(({ config, fromCache }) => {
|
||||||
|
if (config) {
|
||||||
|
setAppConfig(config)
|
||||||
|
if (!config.has_users) setMode('register')
|
||||||
|
// Skip auto-redirect when config is from cache — network is unreliable
|
||||||
|
// and auto-redirecting to the IdP could loop if the proxy changed.
|
||||||
|
if (!fromCache && !config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
|
||||||
|
window.location.href = '/api/auth/oidc/login'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}, [navigate, t, noRedirect])
|
}, [navigate, t, noRedirect])
|
||||||
|
|
||||||
// Language detection chain (runs once on mount, only if user has no saved preference):
|
// Language detection chain (runs once on mount, only if user has no saved preference):
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage';
|
|||||||
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
|
||||||
|
|
||||||
function setSearchParams(search: string) {
|
function setSearchParams(search: string) {
|
||||||
window.history.pushState({}, '', '/oauth/authorize' + search);
|
window.history.pushState({}, '', '/oauth/consent' + search);
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALIDATE_OK = {
|
const VALIDATE_OK = {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
const state = params.get('state') || ''
|
const state = params.get('state') || ''
|
||||||
const codeChallenge = params.get('code_challenge') || ''
|
const codeChallenge = params.get('code_challenge') || ''
|
||||||
const ccMethod = params.get('code_challenge_method') || ''
|
const ccMethod = params.get('code_challenge_method') || ''
|
||||||
|
const resource = params.get('resource') || undefined
|
||||||
|
|
||||||
// Load auth state once, then validate
|
// Load auth state once, then validate
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +44,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return
|
if (authLoading) return
|
||||||
validateRequest()
|
validateRequest()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [authLoading, isAuthenticated])
|
}, [authLoading, isAuthenticated])
|
||||||
|
|
||||||
async function validateRequest() {
|
async function validateRequest() {
|
||||||
@@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: ccMethod,
|
code_challenge_method: ccMethod,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
|
resource,
|
||||||
})
|
})
|
||||||
setValidation(result)
|
setValidation(result)
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: ccMethod,
|
code_challenge_method: ccMethod,
|
||||||
approved,
|
approved,
|
||||||
|
resource,
|
||||||
})
|
})
|
||||||
setPageState('done')
|
setPageState('done')
|
||||||
window.location.href = result.redirect
|
window.location.href = result.redirect
|
||||||
@@ -111,20 +114,20 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
function toggleScope(s: string) {
|
function toggleScope(s: string) {
|
||||||
setSelectedScopes(prev =>
|
setSelectedScopes(prev =>
|
||||||
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
function toggleGroup(groupScopes: string[], allSelected: boolean) {
|
||||||
setSelectedScopes(prev =>
|
setSelectedScopes(prev =>
|
||||||
allSelected
|
allSelected
|
||||||
? prev.filter(s => !groupScopes.includes(s))
|
? prev.filter(s => !groupScopes.includes(s))
|
||||||
: [...new Set([...prev, ...groupScopes])]
|
: [...new Set([...prev, ...groupScopes])]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoginRedirect() {
|
function handleLoginRedirect() {
|
||||||
const next = '/oauth/authorize?' + params.toString() + window.location.hash
|
const next = '/oauth/consent?' + params.toString() + window.location.hash
|
||||||
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
window.location.href = '/login?redirect=' + encodeURIComponent(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,212 +148,212 @@ export default function OAuthAuthorizePage(): React.ReactElement {
|
|||||||
|
|
||||||
if (pageState === 'loading' || pageState === 'auto_approving') {
|
if (pageState === 'loading' || pageState === 'auto_approving') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
{pageState === 'auto_approving' ? 'Authorizing…' : 'Loading…'}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === 'error') {
|
if (pageState === 'error') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-4 text-center" style={{ background: 'var(--bg-card)' }}>
|
||||||
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
<AlertTriangle className="w-10 h-10 mx-auto text-red-500" />
|
||||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Authorization Error</h1>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{errorMsg}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageState === 'login_required') {
|
if (pageState === 'login_required') {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-sm rounded-xl shadow-lg p-8 space-y-5" style={{ background: 'var(--bg-card)' }}>
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<Lock className="w-10 h-10 mx-auto" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
<h1 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>Sign in to continue</h1>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||||
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
<strong>{validation?.client?.name || clientId}</strong> wants access to your TREK account. Please sign in first.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLoginRedirect}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||||
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
Sign in to TREK
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleLoginRedirect}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
|
||||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
|
||||||
<LogIn className="w-4 h-4" />
|
|
||||||
Sign in to TREK
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageState === 'consent'
|
// pageState === 'consent'
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--bg-primary)' }}>
|
||||||
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
<div className="w-full max-w-2xl rounded-xl shadow-lg overflow-hidden flex flex-col sm:flex-row" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
|
||||||
{/* Left panel — app identity + actions */}
|
{/* Left panel — app identity + actions */}
|
||||||
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
<div className="sm:w-64 sm:flex-shrink-0 flex flex-col px-8 py-8 sm:border-r" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
<div className="w-12 h-12 rounded-full flex items-center justify-center" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
<ShieldCheck className="w-6 h-6" style={{ color: 'var(--accent-primary, #4f46e5)' }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
|
||||||
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
|
||||||
{validation?.client?.name || clientId}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
|
||||||
This application is requesting access to your TREK account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 space-y-2">
|
|
||||||
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
|
||||||
Only grant access to applications you trust. Your data stays on your server.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => submitConsent(true)}
|
|
||||||
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
|
||||||
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
|
||||||
{submitting
|
|
||||||
? 'Authorizing…'
|
|
||||||
: validation?.scopeSelectable && selectedScopes.length === 0
|
|
||||||
? 'Select at least one scope'
|
|
||||||
: validation?.scopeSelectable
|
|
||||||
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
|
||||||
: 'Approve Access'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => submitConsent(false)}
|
|
||||||
disabled={submitting}
|
|
||||||
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
|
||||||
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
|
||||||
Deny
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right panel — selectable scopes */}
|
|
||||||
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.keys(scopesByGroup).length > 0 && (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: 'var(--text-tertiary)' }}>Authorization Request</p>
|
||||||
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
<h1 className="text-lg font-semibold leading-snug" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{validation?.client?.name || clientId}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
|
This application is requesting access to your TREK account.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{validation?.scopeSelectable ? (
|
<div className="mt-8 space-y-2">
|
||||||
/* DCR client — user selects which scopes to grant */
|
<p className="text-xs mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
<div className="space-y-3">
|
Only grant access to applications you trust. Your data stays on your server.
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
</p>
|
||||||
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
<button
|
||||||
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
onClick={() => submitConsent(true)}
|
||||||
return (
|
disabled={submitting || (validation?.scopeSelectable === true && selectedScopes.length === 0)}
|
||||||
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium text-white disabled:opacity-60 transition-opacity"
|
||||||
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
style={{ background: 'var(--accent-primary, #4f46e5)' }}>
|
||||||
<input
|
{submitting
|
||||||
type="checkbox"
|
? 'Authorizing…'
|
||||||
checked={allGroupSelected}
|
: validation?.scopeSelectable && selectedScopes.length === 0
|
||||||
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
? 'Select at least one scope'
|
||||||
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
: validation?.scopeSelectable
|
||||||
className="rounded flex-shrink-0"
|
? `Approve (${selectedScopes.length} scope${selectedScopes.length !== 1 ? 's' : ''})`
|
||||||
/>
|
: 'Approve Access'}
|
||||||
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
</button>
|
||||||
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
<button
|
||||||
|
onClick={() => submitConsent(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full px-4 py-2.5 rounded-lg text-sm font-medium border transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-60"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel — selectable scopes */}
|
||||||
|
<div className="flex-1 px-6 py-8 overflow-y-auto max-h-[80vh] sm:max-h-[600px]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.keys(scopesByGroup).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide mb-4" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
|
{validation?.scopeSelectable ? 'Choose which permissions to grant' : 'Permissions requested'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{validation?.scopeSelectable ? (
|
||||||
|
/* DCR client — user selects which scopes to grant */
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
|
||||||
|
const allGroupSelected = groupScopes.every(s => selectedScopes.includes(s))
|
||||||
|
const someGroupSelected = groupScopes.some(s => selectedScopes.includes(s))
|
||||||
|
return (
|
||||||
|
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
|
<label className="flex items-center gap-2.5 px-3 py-2 cursor-pointer" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allGroupSelected}
|
||||||
|
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
|
||||||
|
onChange={() => toggleGroup(groupScopes, allGroupSelected)}
|
||||||
|
className="rounded flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--text-secondary)' }}>{group}</span>
|
||||||
|
<span className="ml-auto text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
{groupScopes.filter(s => selectedScopes.includes(s)).length}/{groupScopes.length}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
{groupScopes.map(s => {
|
{groupScopes.map(s => {
|
||||||
const keys = SCOPE_GROUPS[s]
|
const keys = SCOPE_GROUPS[s]
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={s}
|
key={s}
|
||||||
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedScopes.includes(s)}
|
checked={selectedScopes.includes(s)}
|
||||||
onChange={() => toggleScope(s)}
|
onChange={() => toggleScope(s)}
|
||||||
className="mt-0.5 rounded flex-shrink-0"
|
className="mt-0.5 rounded flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Settings-created client — scopes are fixed, show read-only */
|
|
||||||
<div className="space-y-5">
|
|
||||||
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
|
||||||
<div key={group}>
|
|
||||||
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{groupScopes.map(s => {
|
|
||||||
const keys = SCOPE_GROUPS[s]
|
|
||||||
return (
|
|
||||||
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
|
||||||
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
))}
|
/* Settings-created client — scopes are fixed, show read-only */
|
||||||
|
<div className="space-y-5">
|
||||||
|
{Object.entries(scopesByGroup).map(([group, groupScopes]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<p className="text-xs font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{group}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{groupScopes.map(s => {
|
||||||
|
const keys = SCOPE_GROUPS[s]
|
||||||
|
return (
|
||||||
|
<div key={s} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">
|
||||||
|
{s.endsWith(':delete') ? '🗑️' : s.endsWith(':write') ? '✏️' : '👁️'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{keys ? t(keys.labelKey) : s}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{keys ? t(keys.descriptionKey) : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Always-available tools — granted regardless of scopes */}
|
{/* Always-available tools — granted regardless of scopes */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
<p className="text-xs font-medium uppercase tracking-wide mb-3" style={{ color: 'var(--text-tertiary)' }}>
|
||||||
Always included
|
Always included
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{[
|
{[
|
||||||
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
{ name: 'list_trips', desc: 'List your trips so the AI can discover trip IDs' },
|
||||||
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
{ name: 'get_trip_summary', desc: 'Read a trip overview needed to use any other tool' },
|
||||||
].map(({ name, desc }) => (
|
].map(({ name, desc }) => (
|
||||||
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
<div key={name} className="flex items-start gap-2.5 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
<span className="mt-0.5 text-base leading-none flex-shrink-0">👁️</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
<p className="text-sm font-medium font-mono" style={{ color: 'var(--text-primary)' }}>{name}</p>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,9 @@ import { createElement } from 'react'
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||||
|
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||||
|
import { splitReservationDateTime } from '../utils/formatters'
|
||||||
|
|
||||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
|
|
||||||
function createMarkerIcon(place: any) {
|
function createMarkerIcon(place: any) {
|
||||||
@@ -184,14 +185,16 @@ export default function SharedTripPage() {
|
|||||||
{sortedDays.map((day: any, di: number) => {
|
{sortedDays.map((day: any, di: number) => {
|
||||||
const da = assignments[String(day.id)] || []
|
const da = assignments[String(day.id)] || []
|
||||||
const notes = (dayNotes[String(day.id)] || [])
|
const notes = (dayNotes[String(day.id)] || [])
|
||||||
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
|
const dayAssignmentIds: number[] = da.map((a: any) => a.id)
|
||||||
|
const dayTransport = getTransportForDay({ reservations: reservations || [], dayId: day.id, dayAssignmentIds, days: sortedDays })
|
||||||
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
|
||||||
|
|
||||||
const merged = [
|
const merged = getMergedItems({
|
||||||
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
|
dayAssignments: da,
|
||||||
...notes.map((n: any) => ({ type: 'note', k: n.sort_order ?? 0, data: n })),
|
dayNotes: notes,
|
||||||
...dayTransport.map((r: any) => ({ type: 'transport', k: r.day_plan_position ?? 999, data: r })),
|
dayTransports: dayTransport,
|
||||||
].sort((a, b) => a.k - b.k)
|
dayId: day.id,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
<div key={day.id} style={{ background: 'var(--bg-card, white)', borderRadius: 14, overflow: 'hidden', border: '1px solid var(--border-faint, #e5e7eb)' }}>
|
||||||
@@ -212,12 +215,12 @@ export default function SharedTripPage() {
|
|||||||
|
|
||||||
{selectedDay === day.id && merged.length > 0 && (
|
{selectedDay === day.id && merged.length > 0 && (
|
||||||
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div style={{ padding: '0 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{merged.map((item: any, idx: number) => {
|
{merged.map((item: any) => {
|
||||||
if (item.type === 'transport') {
|
if (item.type === 'transport') {
|
||||||
const r = item.data
|
const r = item.data
|
||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||||
let sub = ''
|
let sub = ''
|
||||||
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||||
@@ -274,8 +277,9 @@ export default function SharedTripPage() {
|
|||||||
{(reservations || []).map((r: any) => {
|
{(reservations || []).map((r: any) => {
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time)
|
||||||
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
const time = rTime ?? ''
|
||||||
|
const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||||
return (
|
return (
|
||||||
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
||||||
|
// Manual route planning: off by default, toggled from the day-plan footer. Mode
|
||||||
|
// (driving/walking) is per-session and selects which travel time the connectors show.
|
||||||
|
const [routeShown, setRouteShown] = useState(false)
|
||||||
|
const [routeProfile, setRouteProfile] = useState<'driving' | 'walking'>('driving')
|
||||||
const [fitKey, setFitKey] = useState<number>(0)
|
const [fitKey, setFitKey] = useState<number>(0)
|
||||||
const initialFitTripId = useRef<number | null>(null)
|
const initialFitTripId = useRef<number | null>(null)
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||||
@@ -398,7 +402,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
})
|
})
|
||||||
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
|
||||||
|
|
||||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId)
|
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId, skipFit) => {
|
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||||
const changed = dayId !== selectedDayId
|
const changed = dayId !== selectedDayId
|
||||||
@@ -891,6 +895,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
accommodations={tripAccommodations}
|
accommodations={tripAccommodations}
|
||||||
|
routeShown={routeShown}
|
||||||
|
routeProfile={routeProfile}
|
||||||
|
onToggleRoute={() => setRouteShown(v => !v)}
|
||||||
|
onSetRouteProfile={setRouteProfile}
|
||||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||||
onExpandedDaysChange={setExpandedDayIds}
|
onExpandedDaysChange={setExpandedDayIds}
|
||||||
pushUndo={pushUndo}
|
pushUndo={pushUndo}
|
||||||
@@ -1003,6 +1011,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)}
|
||||||
collapsed={dayDetailCollapsed}
|
collapsed={dayDetailCollapsed}
|
||||||
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
onToggleCollapse={() => setDayDetailCollapsed(c => !c)}
|
||||||
|
mobile={isMobile}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -1116,7 +1125,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1174,7 +1183,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'dateien' && (
|
{activeTab === 'dateien' && (
|
||||||
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files || []}
|
files={files || []}
|
||||||
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
onUpload={(fd) => tripActions.addFile(tripId, fd)}
|
||||||
@@ -1191,7 +1200,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'collab' && (
|
{activeTab === 'collab' && (
|
||||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
|
||||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
|
<CollabPanel tripId={tripId} tripMembers={tripMembers} collabFeatures={collabFeatures} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../../tests/helpers/msw/server';
|
import { server } from '../../tests/helpers/msw/server';
|
||||||
|
import { journeyApi } from '../api/client';
|
||||||
import { useJourneyStore } from './journeyStore';
|
import { useJourneyStore } from './journeyStore';
|
||||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore';
|
||||||
|
|
||||||
@@ -282,16 +283,64 @@ describe('journeyStore', () => {
|
|||||||
useJourneyStore.setState({ current: detail });
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
const newPhoto = buildPhoto({ id: 91, entry_id: 100 });
|
||||||
server.use(
|
// MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
|
||||||
http.post('/api/journeys/entries/100/photos', () =>
|
// emit upload progress events, which hangs in jsdom+Node. Spy on the API
|
||||||
HttpResponse.json({ photos: [newPhoto] })
|
// layer directly so this test exercises store state management only.
|
||||||
)
|
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any);
|
||||||
);
|
const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' });
|
||||||
const result = await useJourneyStore.getState().uploadPhotos(100, new FormData());
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||||
expect(result).toHaveLength(1);
|
expect(result.succeeded).toHaveLength(1);
|
||||||
expect(result[0].id).toBe(91);
|
expect(result.succeeded[0].id).toBe(91);
|
||||||
|
expect(result.failed).toHaveLength(0);
|
||||||
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
expect(storedEntry?.photos).toHaveLength(2);
|
expect(storedEntry?.photos).toHaveLength(2);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => {
|
||||||
|
const entry = buildEntry({ id: 100, photos: [] });
|
||||||
|
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/journeys/entries/100/photos', () =>
|
||||||
|
HttpResponse.error()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file]);
|
||||||
|
expect(result.succeeded).toHaveLength(0);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
expect(result.failed[0]).toBe(file);
|
||||||
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
|
expect(storedEntry?.photos).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => {
|
||||||
|
const entry = buildEntry({ id: 100, photos: [] });
|
||||||
|
const detail = buildJourneyDetail({ id: 50, entries: [entry] });
|
||||||
|
useJourneyStore.setState({ current: detail });
|
||||||
|
|
||||||
|
const photo1 = buildPhoto({ id: 91, entry_id: 100 });
|
||||||
|
const photo2 = buildPhoto({ id: 92, entry_id: 100 });
|
||||||
|
let callCount = 0;
|
||||||
|
// Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013).
|
||||||
|
// Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly.
|
||||||
|
const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) return { photos: [photo1] } as any;
|
||||||
|
throw Object.assign(new Error('Bad Request'), { response: { status: 400 } });
|
||||||
|
});
|
||||||
|
const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' });
|
||||||
|
const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' });
|
||||||
|
const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined);
|
||||||
|
expect(result.succeeded).toHaveLength(1);
|
||||||
|
expect(result.succeeded[0].id).toBe(photo1.id);
|
||||||
|
expect(result.failed).toHaveLength(1);
|
||||||
|
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
|
||||||
|
expect(storedEntry?.photos).toHaveLength(1);
|
||||||
|
void photo2; // referenced to avoid lint warning
|
||||||
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── deletePhoto ──────────────────────────────────────────────────────────
|
// ── deletePhoto ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { journeyApi } from '../api/client'
|
import { journeyApi } from '../api/client'
|
||||||
|
import { uploadFilesResilient, type ResilientResult, type UploadProgress } from '../utils/uploadQueue'
|
||||||
|
|
||||||
export interface Journey {
|
export interface Journey {
|
||||||
id: number
|
id: number
|
||||||
@@ -121,8 +122,8 @@ interface JourneyState {
|
|||||||
deleteEntry: (entryId: number) => Promise<void>
|
deleteEntry: (entryId: number) => Promise<void>
|
||||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||||
|
|
||||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
uploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||||
uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<GalleryPhoto[]>
|
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>>
|
||||||
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
unlinkPhoto: (entryId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
deleteGalleryPhoto: (journeyId: number, journeyPhotoId: number) => Promise<void>
|
||||||
deletePhoto: (photoId: number) => Promise<void>
|
deletePhoto: (photoId: number) => Promise<void>
|
||||||
@@ -237,32 +238,49 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadPhotos: async (entryId, formData) => {
|
uploadPhotos: async (entryId, files, cbs) => {
|
||||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
return uploadFilesResilient<JourneyPhoto>(
|
||||||
const photos = data.photos || []
|
files,
|
||||||
set(s => {
|
async (file, opts) => {
|
||||||
if (!s.current) return s
|
const fd = new FormData()
|
||||||
return {
|
fd.append('photos', file)
|
||||||
current: {
|
const data = await journeyApi.uploadPhotos(entryId, fd, opts)
|
||||||
...s.current,
|
const photos: JourneyPhoto[] = data.photos || []
|
||||||
entries: s.current.entries.map(e =>
|
const gallery: GalleryPhoto[] = data.gallery || []
|
||||||
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
set(s => {
|
||||||
),
|
if (!s.current) return s
|
||||||
gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
|
return {
|
||||||
},
|
current: {
|
||||||
}
|
...s.current,
|
||||||
})
|
entries: s.current.entries.map(e =>
|
||||||
return photos
|
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
|
||||||
|
),
|
||||||
|
gallery: [...(s.current.gallery || []), ...gallery],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
{ onProgress: cbs?.onProgress },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadGalleryPhotos: async (journeyId, formData) => {
|
uploadGalleryPhotos: async (journeyId, files, cbs) => {
|
||||||
const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
|
return uploadFilesResilient<GalleryPhoto>(
|
||||||
const photos: GalleryPhoto[] = data.photos || []
|
files,
|
||||||
set(s => {
|
async (file, opts) => {
|
||||||
if (!s.current || s.current.id !== journeyId) return s
|
const fd = new FormData()
|
||||||
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
fd.append('photos', file)
|
||||||
})
|
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts)
|
||||||
return photos
|
const photos: GalleryPhoto[] = data.photos || []
|
||||||
|
set(s => {
|
||||||
|
if (!s.current || s.current.id !== journeyId) return s
|
||||||
|
return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
|
||||||
|
})
|
||||||
|
return photos
|
||||||
|
},
|
||||||
|
{ onProgress: cbs?.onProgress },
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
unlinkPhoto: async (entryId, journeyPhotoId) => {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const PROBE_INTERVAL_MS = 30_000
|
||||||
|
const PROBE_TIMEOUT_MS = 1_500
|
||||||
|
|
||||||
|
let reachable = true
|
||||||
|
const listeners = new Set<(v: boolean) => void>()
|
||||||
|
|
||||||
|
function setReachable(v: boolean): void {
|
||||||
|
if (reachable === v) return
|
||||||
|
reachable = v
|
||||||
|
listeners.forEach(fn => fn(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(): Promise<void> {
|
||||||
|
if (!navigator.onLine) { setReachable(false); return }
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const t = setTimeout(() => ctrl.abort(), PROBE_TIMEOUT_MS)
|
||||||
|
const res = await fetch('/api/health', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(t)
|
||||||
|
// /api/health returns JSON. CF Access / Pangolin will either return HTML
|
||||||
|
// (Pangolin 200 auth wall) or trigger a cross-origin redirect that throws
|
||||||
|
// below. Both proxy-auth scenarios resolve to reachable = false.
|
||||||
|
const ct = res.headers.get('content-type') || ''
|
||||||
|
setReachable(res.ok && ct.includes('application/json'))
|
||||||
|
} catch {
|
||||||
|
setReachable(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startConnectivityProbe(): void {
|
||||||
|
probe()
|
||||||
|
setInterval(probe, PROBE_INTERVAL_MS)
|
||||||
|
window.addEventListener('online', probe)
|
||||||
|
window.addEventListener('offline', () => setReachable(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReachable(): boolean { return reachable }
|
||||||
|
export function probeNow(): Promise<void> { return probe() }
|
||||||
|
export function onChange(fn: (v: boolean) => void): () => void {
|
||||||
|
listeners.add(fn)
|
||||||
|
return () => listeners.delete(fn)
|
||||||
|
}
|
||||||
@@ -175,6 +175,7 @@ export interface Reservation {
|
|||||||
accommodation_start_day_id?: number | null
|
accommodation_start_day_id?: number | null
|
||||||
accommodation_end_day_id?: number | null
|
accommodation_end_day_id?: number | null
|
||||||
day_plan_position?: number | null
|
day_plan_position?: number | null
|
||||||
|
day_positions?: Record<number, number> | null
|
||||||
metadata?: Record<string, string> | string | null
|
metadata?: Record<string, string> | string | null
|
||||||
needs_review?: number
|
needs_review?: number
|
||||||
endpoints?: ReservationEndpoint[]
|
endpoints?: ReservationEndpoint[]
|
||||||
@@ -236,8 +237,19 @@ export interface RouteSegment {
|
|||||||
mid: [number, number]
|
mid: [number, number]
|
||||||
from: [number, number]
|
from: [number, number]
|
||||||
to: [number, number]
|
to: [number, number]
|
||||||
|
distance: number
|
||||||
|
duration: number
|
||||||
walkingText: string
|
walkingText: string
|
||||||
drivingText: string
|
drivingText: string
|
||||||
|
distanceText: string
|
||||||
|
durationText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteWithLegs {
|
||||||
|
coordinates: [number, number][]
|
||||||
|
distance: number
|
||||||
|
duration: number
|
||||||
|
legs: RouteSegment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteResult {
|
export interface RouteResult {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
function looksLikeHeic(file: File): boolean {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
return ext === 'heic' || ext === 'heif' || file.type === 'image/heic' || file.type === 'image/heif'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageFile(file: File): Promise<File> {
|
||||||
|
if (!looksLikeHeic(file)) return file
|
||||||
|
const { isHeic, heicTo } = await import('heic-to')
|
||||||
|
if (!(await isHeic(file))) return file
|
||||||
|
const blob = await heicTo({ blob: file, type: 'image/jpeg', quality: 0.92 })
|
||||||
|
const jpegName = file.name.replace(/\.(heic|heif)$/i, '.jpg')
|
||||||
|
return new File([blob], jpegName, { type: 'image/jpeg' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImageFiles(files: FileList | File[]): Promise<File[]> {
|
||||||
|
return Promise.all(Array.from(files).map(normalizeImageFile))
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||||
|
|
||||||
|
describe('parseTimeToMinutes', () => {
|
||||||
|
it('parses HH:MM string', () => {
|
||||||
|
expect(parseTimeToMinutes('09:30')).toBe(570)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses ISO datetime string', () => {
|
||||||
|
expect(parseTimeToMinutes('2025-03-30T14:00:00')).toBe(840)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for null/empty', () => {
|
||||||
|
expect(parseTimeToMinutes(null)).toBeNull()
|
||||||
|
expect(parseTimeToMinutes(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSpanPhase', () => {
|
||||||
|
it('returns single when start === end', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 1 }, 1)).toBe('single')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns start for the departure day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 1)).toBe('start')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns end for the arrival day', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 3)).toBe('end')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns middle for days in between', () => {
|
||||||
|
expect(getSpanPhase({ day_id: 1, end_day_id: 3 }, 2)).toBe('middle')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDisplayTimeForDay', () => {
|
||||||
|
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||||
|
|
||||||
|
it('returns reservation_time on start day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 1)).toBe(r.reservation_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns reservation_end_time on end day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 3)).toBe(r.reservation_end_time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for middle day', () => {
|
||||||
|
expect(getDisplayTimeForDay(r, 2)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTransportForDay', () => {
|
||||||
|
const days = [
|
||||||
|
{ id: 1, day_number: 1 },
|
||||||
|
{ id: 2, day_number: 2 },
|
||||||
|
{ id: 3, day_number: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('excludes hotel (rendered via accommodation path)', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes tour booking on the correct day', () => {
|
||||||
|
const reservations = [{ id: 20, type: 'tour', day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes restaurant, event, and other bookings by day_id', () => {
|
||||||
|
const reservations = [
|
||||||
|
{ id: 30, type: 'restaurant', day_id: 2 },
|
||||||
|
{ id: 31, type: 'event', day_id: 2 },
|
||||||
|
{ id: 32, type: 'other', day_id: 2 },
|
||||||
|
]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(3)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes single-day transport on the correct day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes multi-day transport on all spanned days', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'train', day_id: 1, end_day_id: 3 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 3, dayAssignmentIds: [], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes transport linked to an assignment on that day', () => {
|
||||||
|
const reservations = [{ id: 10, type: 'bus', day_id: 1, end_day_id: 1, assignment_id: 42 }]
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
|
||||||
|
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getMergedItems', () => {
|
||||||
|
it('merges places and notes sorted by sortKey', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: null } },
|
||||||
|
{ id: 2, order_index: 2, place: { place_time: null } },
|
||||||
|
]
|
||||||
|
const dayNotes = [{ id: 10, sort_order: 1 }]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes, dayTransports: [], dayId: 5 })
|
||||||
|
expect(result.map(i => i.type)).toEqual(['place', 'note', 'place'])
|
||||||
|
expect(result[0].data.id).toBe(1)
|
||||||
|
expect(result[1].data.id).toBe(10)
|
||||||
|
expect(result[2].data.id).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inserts transport by time when no per-day position is set', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'flight', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: null },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
// transport (10:30) should be between place at 08:00 (idx 0) and place at 13:00 (idx 1)
|
||||||
|
expect(types).toEqual(['place', 'transport', 'place'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('per-day position overrides time-based insertion', () => {
|
||||||
|
const dayAssignments = [
|
||||||
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
|
]
|
||||||
|
// Transport at 10:30 would normally go between the two places
|
||||||
|
// but per-day position 1.5 puts it after the second place
|
||||||
|
const dayTransports = [
|
||||||
|
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||||
|
]
|
||||||
|
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
|
const types = result.map(i => i.type)
|
||||||
|
expect(types).toEqual(['place', 'place', 'transport'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
|
||||||
|
export interface MergedItem {
|
||||||
|
type: 'place' | 'note' | 'transport'
|
||||||
|
sortKey: number
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTimeToMinutes(time?: string | null): number | null {
|
||||||
|
if (!time) return null
|
||||||
|
if (time.includes('T')) {
|
||||||
|
const [h, m] = time.split('T')[1].split(':').map(Number)
|
||||||
|
return h * 60 + m
|
||||||
|
}
|
||||||
|
const parts = time.split(':').map(Number)
|
||||||
|
if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpanPhase(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null },
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDisplayTimeForDay(
|
||||||
|
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||||
|
export function getTransportForDay(opts: {
|
||||||
|
reservations: any[]
|
||||||
|
dayId: number
|
||||||
|
dayAssignmentIds: number[]
|
||||||
|
days: Array<{ id: number; day_number?: number }>
|
||||||
|
}): any[] {
|
||||||
|
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||||
|
|
||||||
|
const getDayOrder = (id: number): number => {
|
||||||
|
const d = days.find(x => x.id === id)
|
||||||
|
return d ? ((d as any).day_number ?? days.indexOf(d)) : 0
|
||||||
|
}
|
||||||
|
const thisDayOrder = getDayOrder(dayId)
|
||||||
|
|
||||||
|
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 startOrder = getDayOrder(startDayId)
|
||||||
|
const endOrder = getDayOrder(endDayId)
|
||||||
|
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||||
|
}
|
||||||
|
return startDayId === dayId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||||
|
export function getMergedItems(opts: {
|
||||||
|
dayAssignments: any[]
|
||||||
|
dayNotes: any[]
|
||||||
|
dayTransports: any[]
|
||||||
|
dayId: number
|
||||||
|
getDisplayTime?: (r: any, dayId: number) => string | null
|
||||||
|
}): MergedItem[] {
|
||||||
|
const { dayAssignments: da, dayNotes: dn, dayTransports: transport, dayId } = opts
|
||||||
|
const getDisplayTime = opts.getDisplayTime ?? getDisplayTimeForDay
|
||||||
|
|
||||||
|
const baseItems: MergedItem[] = [
|
||||||
|
...da.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })),
|
||||||
|
...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order ?? 0, data: n })),
|
||||||
|
].sort((a, b) => a.sortKey - b.sortKey)
|
||||||
|
|
||||||
|
const timedTransports = transport.map(r => ({
|
||||||
|
type: 'transport' as const,
|
||||||
|
data: r,
|
||||||
|
minutes: parseTimeToMinutes(getDisplayTime(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) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert transports among base items 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
|
||||||
|
|
||||||
|
// Per-day position takes precedence (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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-based fallback: insert after the last item whose 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { splitReservationDateTime } from './formatters'
|
||||||
|
|
||||||
|
describe('splitReservationDateTime', () => {
|
||||||
|
it('parses full ISO datetime', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses full datetime with seconds', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses date-only string', () => {
|
||||||
|
expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses bare HH:MM (new dateless format)', () => {
|
||||||
|
expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses bare single-digit hour time', () => {
|
||||||
|
expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles legacy malformed T-prefixed time ("T10:00")', () => {
|
||||||
|
expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null date for T-prefixed without valid date', () => {
|
||||||
|
const result = splitReservationDateTime('T23:59')
|
||||||
|
expect(result.date).toBeNull()
|
||||||
|
expect(result.time).toBe('23:59')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for null input', () => {
|
||||||
|
expect(splitReservationDateTime(null)).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for undefined input', () => {
|
||||||
|
expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for empty string', () => {
|
||||||
|
expect(splitReservationDateTime('')).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls for unrecognized string', () => {
|
||||||
|
expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t
|
|||||||
} catch { return timeStr }
|
} catch { return timeStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } {
|
||||||
|
if (!value) return { date: null, time: null }
|
||||||
|
const isoDate = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
if (value.includes('T')) {
|
||||||
|
const [d, t] = value.split('T')
|
||||||
|
return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null }
|
||||||
|
}
|
||||||
|
if (isoDate.test(value)) return { date: value, time: null }
|
||||||
|
if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) }
|
||||||
|
return { date: null, time: null }
|
||||||
|
}
|
||||||
|
|
||||||
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null {
|
||||||
const da = assignments[String(dayId)] || []
|
const da = assignments[String(dayId)] || []
|
||||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { AxiosProgressEvent } from 'axios'
|
||||||
|
|
||||||
|
export interface UploadProgress {
|
||||||
|
done: number
|
||||||
|
total: number
|
||||||
|
failed: number
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResilientResult<T> {
|
||||||
|
succeeded: T[]
|
||||||
|
failed: File[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadOpts {
|
||||||
|
onUploadProgress: (e: AxiosProgressEvent) => void
|
||||||
|
idempotencyKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
||||||
|
|
||||||
|
function isRetryable(err: unknown): boolean {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
const status = (err as { response?: { status?: number } }).response?.status
|
||||||
|
if (status !== undefined && status >= 400 && status < 500) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFilesResilient<T>(
|
||||||
|
files: File[],
|
||||||
|
uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
|
||||||
|
cbs?: {
|
||||||
|
concurrency?: number
|
||||||
|
retries?: number
|
||||||
|
onProgress?: (p: UploadProgress) => void
|
||||||
|
onUploaded?: (items: T[]) => void
|
||||||
|
},
|
||||||
|
): Promise<ResilientResult<T>> {
|
||||||
|
const concurrency = cbs?.concurrency ?? 3
|
||||||
|
const maxRetries = cbs?.retries ?? 2
|
||||||
|
|
||||||
|
const totalBytes = files.reduce((s, f) => s + f.size, 0)
|
||||||
|
const loadedMap = new Map<number, number>()
|
||||||
|
let doneCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
const emitProgress = () => {
|
||||||
|
if (!cbs?.onProgress) return
|
||||||
|
const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
|
||||||
|
const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
|
||||||
|
cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded: T[] = []
|
||||||
|
const failedFiles: File[] = []
|
||||||
|
|
||||||
|
let idx = 0
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (true) {
|
||||||
|
const i = idx++
|
||||||
|
if (i >= files.length) break
|
||||||
|
const file = files[i]
|
||||||
|
const idempotencyKey = crypto.randomUUID()
|
||||||
|
loadedMap.set(i, 0)
|
||||||
|
|
||||||
|
let items: T[] | null = null
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 0) await sleep(400 * attempt)
|
||||||
|
try {
|
||||||
|
items = await uploadOne(file, {
|
||||||
|
idempotencyKey,
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
loadedMap.set(i, e.loaded)
|
||||||
|
emitProgress()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
break
|
||||||
|
} catch (err) {
|
||||||
|
if (!isRetryable(err) || attempt === maxRetries) {
|
||||||
|
items = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items !== null) {
|
||||||
|
succeeded.push(...items)
|
||||||
|
cbs?.onUploaded?.(items)
|
||||||
|
loadedMap.set(i, file.size)
|
||||||
|
doneCount++
|
||||||
|
} else {
|
||||||
|
failedFiles.push(file)
|
||||||
|
loadedMap.set(i, 0)
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
emitProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
|
||||||
|
await Promise.all(workers)
|
||||||
|
|
||||||
|
return { succeeded, failed: failedFiles }
|
||||||
|
}
|
||||||
@@ -9,13 +9,13 @@ import type { RouteSegment } from '../../../src/types';
|
|||||||
|
|
||||||
// Mock the RouteCalculator module to avoid real OSRM fetch calls
|
// Mock the RouteCalculator module to avoid real OSRM fetch calls
|
||||||
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
||||||
calculateSegments: vi.fn(),
|
calculateRouteWithLegs: vi.fn(),
|
||||||
calculateRoute: vi.fn(),
|
calculateRoute: vi.fn(),
|
||||||
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
|
||||||
generateGoogleMapsUrl: vi.fn(),
|
generateGoogleMapsUrl: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator');
|
const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator');
|
||||||
|
|
||||||
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
||||||
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
||||||
@@ -27,14 +27,23 @@ function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssig
|
|||||||
|
|
||||||
const MOCK_SEGMENTS: RouteSegment[] = [
|
const MOCK_SEGMENTS: RouteSegment[] = [
|
||||||
{
|
{
|
||||||
from: [48.8566, 2.3522],
|
distance: 343000,
|
||||||
to: [51.5074, -0.1278],
|
duration: 12600,
|
||||||
mid: [50.182, 1.1122],
|
distanceText: '343 km',
|
||||||
walkingText: '120 min',
|
durationText: '3 h 30 min',
|
||||||
drivingText: '90 min',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Empty coordinates make the hook fall back to the straight-line geometry,
|
||||||
|
// so the `route` assertions keep checking the raw waypoints while the legs
|
||||||
|
// still flow through to `routeSegments`.
|
||||||
|
const MOCK_ROUTE_WITH_LEGS = {
|
||||||
|
coordinates: [] as [number, number][],
|
||||||
|
distance: 343000,
|
||||||
|
duration: 12600,
|
||||||
|
legs: MOCK_SEGMENTS,
|
||||||
|
};
|
||||||
|
|
||||||
describe('useRouteCalculation', () => {
|
describe('useRouteCalculation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -42,7 +51,7 @@ describe('useRouteCalculation', () => {
|
|||||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
||||||
// Reset trip store assignments so each test starts clean
|
// Reset trip store assignments so each test starts clean
|
||||||
useTripStore.setState({ assignments: {} } as any);
|
useTripStore.setState({ assignments: {} } as any);
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS);
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_ROUTE_WITH_LEGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
|
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
|
||||||
@@ -84,7 +93,7 @@ describe('useRouteCalculation', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateSegments', async () => {
|
it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateRouteWithLegs', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||||
@@ -99,11 +108,11 @@ describe('useRouteCalculation', () => {
|
|||||||
|
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
|
|
||||||
expect(calculateSegments).toHaveBeenCalled();
|
expect(calculateRouteWithLegs).toHaveBeenCalled();
|
||||||
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
|
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateSegments', async () => {
|
it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateRouteWithLegs', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||||
@@ -118,7 +127,7 @@ describe('useRouteCalculation', () => {
|
|||||||
|
|
||||||
await act(async () => {});
|
await act(async () => {});
|
||||||
|
|
||||||
expect(calculateSegments).not.toHaveBeenCalled();
|
expect(calculateRouteWithLegs).not.toHaveBeenCalled();
|
||||||
expect(result.current.routeSegments).toEqual([]);
|
expect(result.current.routeSegments).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,13 +172,13 @@ describe('useRouteCalculation', () => {
|
|||||||
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
|
it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||||
|
|
||||||
// Make calculateSegments resolve slowly
|
// Make calculateRouteWithLegs resolve slowly
|
||||||
let resolveSegments!: (val: RouteSegment[]) => void;
|
let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||||
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
|
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
|
||||||
return new Promise<RouteSegment[]>((resolve) => {
|
return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((resolve) => {
|
||||||
resolveSegments = resolve;
|
resolveSegments = resolve;
|
||||||
options?.signal?.addEventListener('abort', () => resolve([]));
|
options?.signal?.addEventListener('abort', () => resolve(MOCK_ROUTE_WITH_LEGS));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -191,12 +200,12 @@ describe('useRouteCalculation', () => {
|
|||||||
rerender({ dayId: 6 });
|
rerender({ dayId: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// calculateSegments should have been called at least once for day 5
|
// calculateRouteWithLegs should have been called at least once for day 5
|
||||||
// and once more for day 6
|
// and once more for day 6
|
||||||
expect((calculateSegments as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
|
expect((calculateRouteWithLegs as ReturnType<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
resolveSegments?.([]);
|
resolveSegments?.(MOCK_ROUTE_WITH_LEGS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
|
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => {
|
||||||
@@ -204,7 +213,7 @@ describe('useRouteCalculation', () => {
|
|||||||
|
|
||||||
const abortError = new Error('Aborted');
|
const abortError = new Error('Aborted');
|
||||||
abortError.name = 'AbortError';
|
abortError.name = 'AbortError';
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||||
@@ -224,7 +233,7 @@ describe('useRouteCalculation', () => {
|
|||||||
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
|
it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => {
|
||||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||||
|
|
||||||
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||||
|
|||||||
@@ -91,8 +91,12 @@ describe('isRtlLanguage', () => {
|
|||||||
describe('SUPPORTED_LANGUAGES', () => {
|
describe('SUPPORTED_LANGUAGES', () => {
|
||||||
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
|
||||||
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
|
||||||
expect(SUPPORTED_LANGUAGES).toHaveLength(15)
|
expect(SUPPORTED_LANGUAGES).toHaveLength(20)
|
||||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ko', label: '한국어' }))
|
||||||
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'uk', label: 'Українська' }))
|
||||||
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
|
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
// Smoke test: proves the client toolchain (vite / vitest) resolves @trek/shared.
|
||||||
|
import { idParamSchema, paginationQuerySchema } from '@trek/shared';
|
||||||
|
|
||||||
|
describe('@trek/shared resolves in the client toolchain', () => {
|
||||||
|
it('imports and uses a shared schema', () => {
|
||||||
|
expect(idParamSchema.parse('7')).toBe(7);
|
||||||
|
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,11 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@trek/shared": ["../shared/src/index.ts"],
|
||||||
|
"@trek/shared/*": ["../shared/src/*"]
|
||||||
|
},
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|||||||
+27
-4
@@ -11,7 +11,7 @@ export default defineConfig({
|
|||||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
|
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/, /^\/oauth\//, /^\/.well-known\//],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
// Carto map tiles (default provider)
|
// Carto map tiles (default provider)
|
||||||
@@ -46,7 +46,7 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
// API calls — prefer network, fall back to cache
|
// API calls — prefer network, fall back to cache
|
||||||
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||||
urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i,
|
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||||
handler: 'NetworkFirst',
|
handler: 'NetworkFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'api-data',
|
cacheName: 'api-data',
|
||||||
@@ -90,7 +90,7 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
modulePreload: { polyfill: false },
|
modulePreload: { polyfill: true },
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
@@ -110,7 +110,30 @@ export default defineConfig({
|
|||||||
'/mcp': {
|
'/mcp': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
},
|
||||||
|
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
|
||||||
|
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
|
||||||
|
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
|
||||||
|
'/oauth/authorize': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/oauth/token': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/oauth/register': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/oauth/revoke': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/.well-known': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+9215
-2183
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@trek/root",
|
||||||
|
"private": true,
|
||||||
|
"version": "3.0.22",
|
||||||
|
"workspaces": [
|
||||||
|
"client",
|
||||||
|
"server",
|
||||||
|
"shared"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"version:major": "npm version major --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:minor": "npm version minor --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:patch": "npm version patch --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:premajor": "npm version premajor --preid=rc --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:preminor": "npm version preminor --preid=beta --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:prepatch": "npm version prepatch --preid=alpha --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"version:prerelease": "npm version prerelease --preid=pre --workspaces --include-workspace-root --no-git-tag-version",
|
||||||
|
"dev": "npm run build --workspace=shared && concurrently --names shared,server,client \"npm run build:watch --workspace=shared\" \"npm run dev --workspace=server\" \"npm run dev --workspace=client\"",
|
||||||
|
"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=client",
|
||||||
|
"test": "npm run test --workspace=shared && npm run test --workspace=server && npm run test --workspace=client",
|
||||||
|
"test:cov": "npm run test:coverage --workspace=server && npm run test:coverage --workspace=client",
|
||||||
|
"test:e2e": "npm run test:e2e --workspace=server",
|
||||||
|
"lint": "npm run lint --workspace=shared && npm run lint --workspace=server && npm run lint --workspace=client",
|
||||||
|
"format": "npm run format --workspace=shared && npm run format --workspace=server && npm run format --workspace=client",
|
||||||
|
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"concurrently": "^9.2.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||||
|
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.33.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
PORT=3001 # Port to run the server on
|
PORT=3001 # Port to run the server on
|
||||||
|
# HOST=0.0.0.0 # Bind address for the HTTP server. Only set this when running TREK from sources or via the Proxmox community script — never in Docker (the container handles binding).
|
||||||
NODE_ENV=development # development = development mode; production = production mode
|
NODE_ENV=development # development = development mode; production = production mode
|
||||||
# ENCRYPTION_KEY=<random-256-bit-hex> # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.)
|
# ENCRYPTION_KEY=<random-256-bit-hex> # Separate key for encrypting stored secrets (API keys, MFA, SMTP, OIDC, etc.)
|
||||||
# Auto-generated and persisted to ./data/.encryption_key if not set.
|
# Auto-generated and persisted to ./data/.encryption_key if not set.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-organize-imports",
|
||||||
|
"@trivago/prettier-plugin-sort-imports"
|
||||||
|
],
|
||||||
|
"importOrder": [
|
||||||
|
"^[a-zA-Z]",
|
||||||
|
"^@/.*"
|
||||||
|
],
|
||||||
|
"importOrderSeparation": true,
|
||||||
|
"importOrderParserPlugins": [
|
||||||
|
"typescript",
|
||||||
|
"decorators-legacy"
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
-7216
File diff suppressed because it is too large
Load Diff
+38
-7
@@ -1,19 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-server",
|
"name": "@trek/server",
|
||||||
"version": "3.0.13",
|
"version": "3.0.22",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --import tsx src/index.ts",
|
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "node scripts/dev.mjs",
|
||||||
|
"build": "node scripts/build.mjs",
|
||||||
|
"start:prod": "node --require tsconfig-paths/register dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:ws": "vitest run tests/websocket",
|
"test:ws": "vitest run tests/websocket",
|
||||||
|
"test:parity": "vitest run tests/parity",
|
||||||
|
"test:e2e": "vitest run tests/e2e",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@trek/shared": "*",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
|
"@nestjs/common": "^11.1.24",
|
||||||
|
"@nestjs/core": "^11.1.24",
|
||||||
|
"@nestjs/platform-express": "^11.1.24",
|
||||||
"archiver": "^6.0.1",
|
"archiver": "^6.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
"better-sqlite3": "^12.8.0",
|
||||||
@@ -30,20 +43,37 @@
|
|||||||
"nodemailer": "^8.0.5",
|
"nodemailer": "^8.0.5",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.2",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"undici": "^7.0.0",
|
"undici": "^7.0.0",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "^14.0.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.21.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"hono": "^4.12.12",
|
"hono": "^4.12.16",
|
||||||
"@hono/node-server": "^1.19.13"
|
"@hono/node-server": "^1.19.13",
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
|
"ip-address": "^10.1.1",
|
||||||
|
"multer": "^2.1.1",
|
||||||
|
"ws": "^8.21.0",
|
||||||
|
"qs": "^6.15.2",
|
||||||
|
"file-type": "^21.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
|
"@nestjs/testing": "^11.1.24",
|
||||||
|
"@swc/core": "^1.15.40",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
@@ -65,6 +95,7 @@
|
|||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tz-lookup": "^6.1.25",
|
"tz-lookup": "^6.1.25",
|
||||||
|
"unplugin-swc": "^1.5.9",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user