Compare commits

...

18 Commits

Author SHA1 Message Date
Maurice 900295ffb6 Merge branch 'dev' into pr-1029-tr
# Conflicts:
#	client/src/i18n/TranslationContext.tsx
#	client/src/i18n/supportedLanguages.ts
2026-05-25 18:25:05 +02:00
sss3978 be71cae0d3 feat(i18n): add Japanese (ja) translation (#829)
Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext.
2026-05-25 18:22:39 +02:00
ppuassi ee2089e81d feat(i18n): add Korean (ko) translation (#977)
Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately.
2026-05-25 18:22:35 +02:00
Maurice 978314b9de i18n(tr): add the 8 keys EN gained since this PR was opened
Brings tr.ts to full parity with the current en.ts (journey upload progress/
errors + OAuth machine-client strings).
2026-05-25 18:12:19 +02:00
gzor 352f94612d fix(packing): multiply item weight by quantity in bag/total weight calcs (#898)
Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests.
2026-05-25 17:59:54 +02:00
Maurice 0257e4e71e feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053)
First strangler migration (L1): /api/weather is served by a NestJS module.

- @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged).
- Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR.
- Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract.
- Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide.
- Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone.
2026-05-25 17:00:58 +02:00
Maurice 0b218d53b2 Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)
Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in.

F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage.

Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
2026-05-25 14:29:30 +02:00
github-actions[bot] e27be5c965 chore: bump version to 3.0.22 [skip ci] 2026-05-24 23:13:41 +00:00
Julien G. 86ee8044da v3.0.22 Bug Fixes & Improvements (#1041)
Bundles the v3.0.22 bug fixes and improvements. See the release notes for the full list.
2026-05-25 01:13:20 +02:00
Maurice 75772445a7 Update security contact email in SECURITY.md 2026-05-24 19:39:53 +02:00
Copilot 134b420cd1 Add Turkish (tr) to client i18n and language registry
* feat(i18n): add Turkish language support and base tr translations

Agent-Logs-Url: https://github.com/SkyLostTR/TREK/sessions/f86511d9-8ff1-4459-8ec1-879936135741

Co-authored-by: SkyLostTR <21984261+SkyLostTR@users.noreply.github.com>

* test(i18n): update language list expectations for Turkish support
2026-05-19 16:00:53 +03:00
github-actions[bot] bfe6664ac4 chore: bump version to 3.0.21 [skip ci] 2026-05-15 22:53:13 +00:00
Julien G. 117942f45e v3.0.21 Bug Fixes (#998)
* fix(journey): remove photo upload count limit and surface upload errors (#997)

Removes the arbitrary 10-file cap on journey entry photo uploads and 20-file
cap on gallery uploads. MulterErrors now return proper 4xx responses instead
of 500, and the client surfaces the server error message via toast rather than
silently trapping the user in the post editor overlay.

* fix(planner): remove correct assignment when place assigned to same day multiple times

When a place was assigned to the same day more than once, the "Remove from day"
button in PlaceInspector always deleted the first assignment (Array.find on
place.id) instead of the currently selected one. Now prefers selectedAssignmentId
when available.

Fixes #1005

* fix(map): enable 3D terrain for Mapbox outdoors style in trip planner

wantsTerrain() only matched satellite styles, so the outdoors-v12 style
was flat in the planner despite showing correct 3D terrain in the settings
preview. Added outdoors-v12 to the allowlist; marker drift is already
handled by syncMarkerAltitudes().

Fixes #1002

* fix(maps): send Referer header on Google API calls when APP_URL is set

Supports HTTP referrer restrictions on GCP API keys. Documents the
restriction types and photo troubleshooting steps in the wiki.
2026-05-16 00:53:02 +02:00
Julien G. e7211325df Add asset.download permission to Photo Providers 2026-05-15 23:16:34 +02:00
github-actions[bot] 7e49f3467c chore: bump version to 3.0.20 [skip ci] 2026-05-13 08:35:23 +00:00
jubnl 93b51a0bf5 fix(csp): allow unsafe-eval for HEIC image conversion 2026-05-13 10:34:57 +02:00
github-actions[bot] 5b710a429a chore: bump version to 3.0.19 [skip ci] 2026-05-13 08:13:30 +00:00
Julien G. da3cba2de3 v3.0.19 Bug Fixes (#992)
* fix(mcp): replace relative oauth constent redirect by absolute redirect derived from APP_URL (#987)

* feat(journey): convert HEIC/HEIF uploads to JPEG for cross-platform compatibility

HEIC is an Apple-only format not recognised as an image by many browsers
and platforms. heic-to (lazy-loaded) now converts HEIC/HEIF files to JPEG
before upload in both the gallery and entry editor photo pickers.
Embedded metadata (EXIF, GPS) may be lost during conversion — documented
in the Journey Journal wiki page.

* fix(journey): skip heic-to import for non-HEIC files to avoid test env failures

* fix(notifications): prevent double-escaping HTML in password reset emails

buildPasswordResetHtml passed a pre-built HTML block to buildEmailHtml,
which then escaped it again — rendering raw tags as plain text in the email.
2026-05-13 10:13:17 +02:00
134 changed files with 13915 additions and 759 deletions
+33 -1
View File
@@ -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: 22
cache: npm
cache-dependency-path: shared/package-lock.json
- name: Install dependencies
run: cd shared && npm ci
- 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
@@ -28,6 +51,15 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: cd server && npm ci run: cd server && npm ci
- name: Build (tsc + tsc-alias -> dist)
run: cd server && npm run build
- name: Typecheck (informational)
# Legacy code still has pre-existing type errors; this surfaces them
# without blocking the migration. Ratchet to blocking once 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
+1
View File
@@ -3,6 +3,7 @@ node_modules/
# Build output # Build output
client/dist/ client/dist/
server/dist/
server/public/* server/public/*
!server/public/.gitkeep !server/public/.gitkeep
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

+524
View File
@@ -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)
+405
View File
@@ -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
+15 -5
View File
@@ -6,7 +6,18 @@ RUN npm ci
COPY client/ ./ COPY client/ ./
RUN npm run build RUN npm run build
# Stage 2: Production server # Stage 2: Build server (TypeScript -> dist via tsc + tsc-alias)
# --ignore-scripts: tsc only transpiles, so we skip native builds (better-sqlite3)
# here; the production stage builds the native module.
FROM node:24-alpine AS server-builder
WORKDIR /app
COPY server/package*.json ./
RUN npm ci --ignore-scripts
COPY server/ ./
RUN npm run build
# Stage 3: Production server (runs the compiled JS — NestJS DI needs the
# decorator metadata that tsc emits; the old tsx runtime did not).
FROM node:24-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app
@@ -19,12 +30,11 @@ RUN apk add --no-cache tzdata dumb-init su-exec python3 make g++ && \
apk del python3 make g++ && \ apk del python3 make g++ && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
COPY server/ ./ COPY --from=server-builder /app/dist ./dist
COPY --from=client-builder /app/client/dist ./public COPY --from=client-builder /app/client/dist ./public
COPY --from=client-builder /app/client/public/fonts ./public/fonts COPY --from=client-builder /app/client/public/fonts ./public/fonts
RUN rm -f package-lock.json && \ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \ mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data && \
chown -R node:node /app chown -R node:node /app
@@ -39,4 +49,4 @@ 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"] CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; exec su-exec node node dist/index.js"]
+1 -1
View File
@@ -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>
&nbsp; &nbsp;
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a> <a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp; &nbsp;
+1 -1
View File
@@ -14,7 +14,7 @@ Only the latest version receives security updates. Please update to the latest r
If you discover a security vulnerability, please report it responsibly: If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public issue 1. **Do not** open a public issue
2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch** 2. Email: **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.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.18 version: 3.0.22
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.18" appVersion: "3.0.22"
+19 -77
View File
@@ -1,16 +1,17 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.18", "version": "3.0.22",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "3.0.18", "version": "3.0.22",
"dependencies": { "dependencies": {
"@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",
@@ -27,6 +28,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": {
@@ -2152,9 +2154,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2172,9 +2171,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2192,9 +2188,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2210,9 +2203,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2230,9 +2220,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2250,9 +2237,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later", "license": "LGPL-3.0-or-later",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2270,9 +2254,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2296,9 +2277,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2322,9 +2300,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2346,9 +2321,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2372,9 +2344,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2398,9 +2367,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3159,9 +3125,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3176,9 +3139,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3193,9 +3153,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3210,9 +3167,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3227,9 +3181,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3244,9 +3195,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3261,9 +3209,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3278,9 +3223,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3295,9 +3237,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3312,9 +3251,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3329,9 +3265,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3344,9 +3277,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3361,9 +3291,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5827,6 +5754,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/heic-to": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/heic-to/-/heic-to-1.4.2.tgz",
"integrity": "sha512-y69thwxfNcEm2Vk8lbOD/cMabnvMJyOREfJYiCHcXCDqlfcPyJoBhyRc8+iDe1B95LRfpbTOpzxzY1xbRkdwBA==",
"license": "LGPL-3.0"
},
"node_modules/hsl-to-hex": { "node_modules/hsl-to-hex": {
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
@@ -11041,6 +10974,15 @@
"version": "3.2.1", "version": "3.2.1",
"license": "MIT" "license": "MIT"
}, },
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": { "node_modules/zustand": {
"version": "4.5.7", "version": "4.5.7",
"license": "MIT", "license": "MIT",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.18", "version": "3.0.22",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -18,6 +18,7 @@
"@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 +35,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": {
+18 -5
View File
@@ -1,4 +1,5 @@
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 { isReachable, probeNow } from '../sync/connectivity'
import en from '../i18n/translations/en' import en from '../i18n/translations/en'
@@ -209,7 +210,7 @@ export const oauthApi = {
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),
@@ -407,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),
@@ -489,8 +502,8 @@ 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 = {
@@ -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
+31
View File
@@ -132,6 +132,7 @@ export function MapViewGL({
places = [], places = [],
dayPlaces = [], dayPlaces = [],
route = null, route = null,
routeSegments = [],
selectedPlaceId = null, selectedPlaceId = null,
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
@@ -162,6 +163,7 @@ export function MapViewGL({
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map()) const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null) const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null) const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([])
// Refs so the reservation overlay always sees the latest callback / // Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change. // options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick) const onReservationClickRef = useRef(onReservationClick)
@@ -442,6 +444,35 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features }) src.setData({ type: 'FeatureCollection', features })
}, [route]) }, [route])
// Travel-time pills between consecutive places. The GL map accepted the
// routeSegments prop but never drew anything, so the labels that Leaflet
// shows were missing here (#850). Render them as HTML markers, matching the
// Leaflet pill styling.
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
routeLabelMarkersRef.current.forEach(m => m.remove())
routeLabelMarkersRef.current = []
for (const seg of routeSegments) {
if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue
const el = document.createElement('div')
el.style.pointerEvents = 'none'
el.innerHTML = `<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);">
<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>${seg.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>${seg.drivingText ?? ''}</span>
</div>`
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
.setLngLat([seg.mid[1], seg.mid[0]])
.addTo(map)
routeLabelMarkersRef.current.push(m)
}
return () => {
routeLabelMarkersRef.current.forEach(m => m.remove())
routeLabelMarkersRef.current = []
}
}, [routeSegments, mapReady])
// Update GPX geometries // Update GPX geometries
useEffect(() => { useEffect(() => {
const map = mapRef.current const map = mapRef.current
+6 -4
View File
@@ -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
+2 -1
View File
@@ -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>
) )
})} })}
@@ -28,7 +28,7 @@ import {
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
type MergedItem, type MergedItem,
} from '../../utils/dayMerge' } from '../../utils/dayMerge'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' 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 } from '../../types'
@@ -1487,15 +1487,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
@@ -1722,18 +1724,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' }}>
@@ -1782,8 +1786,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
@@ -2094,13 +2107,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">
+13 -1
View File
@@ -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>
) )
+4 -2
View File
@@ -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,7 @@ 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 { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages' import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES } export { SUPPORTED_LANGUAGES }
@@ -23,7 +25,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,
} }
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here. // Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
@@ -38,7 +40,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'].includes(language) ? language : 'en'
} }
export function isRtlLanguage(language: string): boolean { export function isRtlLanguage(language: string): boolean {
+2
View File
@@ -12,8 +12,10 @@ 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' },
] as const ] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value'] export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
+8
View File
@@ -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': 'جعله الأول',
+8
View File
@@ -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.',
+8
View File
@@ -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.',
+8
View File
@@ -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.',
+8
View File
@@ -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
+8
View File
@@ -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.',
+8
View File
@@ -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é.',
+8
View File
@@ -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.',
+8
View File
@@ -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
+8
View File
@@ -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.',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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.',
+8
View File
@@ -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ł.',
+8
View File
@@ -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': 'Это путешествие не существует или ссылка устарела.',
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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': '此旅程不存在或链接已过期。',
+8
View File
@@ -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': '此旅程不存在或連結已過期。',
+45 -22
View File
@@ -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')}</>
)} )}
+5 -3
View File
@@ -12,6 +12,7 @@ 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 { getTransportForDay, getMergedItems } from '../utils/dayMerge'
import { splitReservationDateTime } from '../utils/formatters'
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 }
@@ -219,7 +220,7 @@ export default function SharedTripPage() {
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(' · ')
@@ -276,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 }}>
+2 -1
View File
@@ -1003,6 +1003,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 +1117,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) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(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 }} />
: <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>
+57 -8
View File
@@ -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 ──────────────────────────────────────────────────────────
+44 -26
View File
@@ -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) => {
+17
View File
@@ -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))
}
+17 -1
View File
@@ -57,11 +57,27 @@ describe('getTransportForDay', () => {
{ id: 3, day_number: 3 }, { id: 3, day_number: 3 },
] ]
it('excludes non-transport types', () => { it('excludes hotel (rendered via accommodation path)', () => {
const reservations = [{ id: 10, type: 'hotel', day_id: 1 }] const reservations = [{ id: 10, type: 'hotel', day_id: 1 }]
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })).toHaveLength(0) 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', () => { it('includes single-day transport on the correct day', () => {
const reservations = [{ id: 10, type: 'flight', day_id: 1, end_day_id: 1 }] 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: 1, dayAssignmentIds: [], days })).toHaveLength(1)
+1 -1
View File
@@ -55,7 +55,7 @@ export function getTransportForDay(opts: {
const thisDayOrder = getDayOrder(dayId) const thisDayOrder = getDayOrder(dayId)
return reservations.filter(r => { return reservations.filter(r => {
if (!TRANSPORT_TYPES.has(r.type)) return false if (r.type === 'hotel') return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id const startDayId = r.day_id
+50
View File
@@ -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 })
})
})
+12
View File
@@ -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)
+106
View File
@@ -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 }
}
+3 -1
View File
@@ -91,8 +91,10 @@ 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(17)
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: 'ar', label: 'العربية' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
}) })
}) })
+10
View File
@@ -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 });
});
});
+5
View File
@@ -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,
+11 -1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { fileURLToPath } from 'node:url'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@@ -88,9 +89,18 @@ export default defineConfig({
}, },
}), }),
], ],
resolve: {
alias: {
// @trek/shared — Zod contract package (dev: resolved to TS source).
'@trek/shared': fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)),
},
// @trek/shared imports zod from its own source; it lives outside this root,
// so pin zod to the client's copy (one instance, resolvable from anywhere).
dedupe: ['zod'],
},
build: { build: {
sourcemap: false, sourcemap: false,
modulePreload: { polyfill: false }, modulePreload: { polyfill: true },
}, },
server: { server: {
port: 5173, port: 5173,
+11
View File
@@ -1,8 +1,19 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
// @trek/shared — Zod contract package (tests resolve it to TS source,
// mirroring the alias in vite.config.js used by the dev server / build).
'@trek/shared': fileURLToPath(new URL('../shared/src/index.ts', import.meta.url)),
},
// Mirror vite.config.js: keep a single zod instance resolvable from the
// shared source, which lives outside this project root.
dedupe: ['zod'],
},
test: { test: {
root: '.', root: '.',
globals: true, globals: true,
+1280 -78
View File
File diff suppressed because it is too large Load Diff
+23 -5
View File
@@ -1,19 +1,27 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.18", "version": "3.0.22",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node dist/index.js",
"dev": "tsx watch src/index.ts", "dev": "node scripts/dev.mjs",
"build": "node scripts/build.mjs",
"start:prod": "node dist/index.js",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
"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": {
"@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,22 +38,30 @@
"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.16", "hono": "^4.12.16",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^1.19.13",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
"ip-address": "^10.1.1" "ip-address": "^10.1.1",
"multer": "^2.1.1",
"ws": "^8.21.0",
"qs": "^6.15.2",
"file-type": "^21.3.4"
}, },
"devDependencies": { "devDependencies": {
"@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",
@@ -66,7 +82,9 @@
"@vitest/coverage-v8": "^3.2.4", "@vitest/coverage-v8": "^3.2.4",
"nodemon": "^3.1.0", "nodemon": "^3.1.0",
"supertest": "^7.2.2", "supertest": "^7.2.2",
"tsc-alias": "^1.8.17",
"tz-lookup": "^6.1.25", "tz-lookup": "^6.1.25",
"unplugin-swc": "^1.5.9",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
} }
+14
View File
@@ -0,0 +1,14 @@
import { execSync } from 'node:child_process';
// tsc emits JS even with type errors (noEmitOnError:false), but still exits
// non-zero to report them. We must run tsc-alias regardless, so run tsc in a
// try/catch and always proceed to the path-rewrite step.
// Type correctness is enforced separately via `npm run typecheck`.
try {
execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' });
} catch {
console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).');
}
execSync('tsc-alias -p tsconfig.build.json', { stdio: 'inherit' });
console.log('[build] dist ready (path aliases rewritten).');
+22
View File
@@ -0,0 +1,22 @@
import { execSync, spawn } from 'node:child_process';
// Dev runtime for the co-hosted NestJS + legacy Express server.
// NestJS DI needs decorator metadata, which the old tsx/esbuild runtime does not
// emit — so dev runs the tsc build with watchers (same toolchain as prod `dist`).
// Initial build first so `node --watch dist/index.js` has something to start.
console.log('[dev] initial build...');
execSync('node scripts/build.mjs', { stdio: 'inherit' });
const watchers = [
['npx', ['tsc', '-w', '-p', 'tsconfig.build.json', '--preserveWatchOutput']],
['npx', ['tsc-alias', '-w', '-p', 'tsconfig.build.json']],
['node', ['--watch', 'dist/index.js']],
];
const children = watchers.map(([cmd, args]) =>
spawn(cmd, args, { stdio: 'inherit', shell: true }),
);
const stop = () => { children.forEach((c) => { try { c.kill(); } catch {} }); process.exit(0); };
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
+9 -4
View File
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import multer from 'multer';
import { logDebug, logWarn, logError } from './services/auditLog'; import { logDebug, logWarn, logError } from './services/auditLog';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth'; import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
@@ -25,7 +26,6 @@ import airportsRoutes from './routes/airports';
import filesRoutes from './routes/files'; import filesRoutes from './routes/files';
import reservationsRoutes from './routes/reservations'; import reservationsRoutes from './routes/reservations';
import dayNotesRoutes from './routes/dayNotes'; import dayNotesRoutes from './routes/dayNotes';
import weatherRoutes from './routes/weather';
import settingsRoutes from './routes/settings'; import settingsRoutes from './routes/settings';
import budgetRoutes from './routes/budget'; import budgetRoutes from './routes/budget';
import collabRoutes from './routes/collab'; import collabRoutes from './routes/collab';
@@ -122,7 +122,7 @@ export function createApp(): express.Application {
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'"], scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
imgSrc: ["'self'", "data:", "blob:", "https:"], imgSrc: ["'self'", "data:", "blob:", "https:"],
connectSrc: [ connectSrc: [
@@ -360,7 +360,8 @@ export function createApp(): express.Application {
app.use('/api/photos', photoRoutes); app.use('/api/photos', photoRoutes);
app.use('/api/maps', mapsRoutes); app.use('/api/maps', mapsRoutes);
app.use('/api/airports', airportsRoutes); app.use('/api/airports', airportsRoutes);
app.use('/api/weather', weatherRoutes); // /api/weather is served by the NestJS weather module (see src/nest/weather);
// the legacy Express route was decommissioned after the migration (L1).
app.use('/api/settings', settingsRoutes); app.use('/api/settings', settingsRoutes);
app.use('/api/system-notices', systemNoticesRoutes); app.use('/api/system-notices', systemNoticesRoutes);
app.use('/api/backup', backupRoutes); app.use('/api/backup', backupRoutes);
@@ -396,7 +397,7 @@ export function createApp(): express.Application {
revocation_endpoint: `${base}/oauth/revoke`, revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`, registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'], response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'], grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
code_challenge_methods_supported: ['S256'], code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES, scopes_supported: ALL_SCOPES,
@@ -507,6 +508,10 @@ export function createApp(): express.Application {
} else { } else {
console.error('Unhandled error:', err); console.error('Unhandled error:', err);
} }
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
return res.status(status).json({ error: err.message });
}
const status = err.statusCode || err.status || 500; const status = err.statusCode || err.status || 500;
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx. // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
const message = status < 500 ? err.message : 'Internal server error'; const message = status < 500 ? err.message : 'Internal server error';
+36
View File
@@ -2229,6 +2229,42 @@ function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`) db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`)
db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`); db.exec(`UPDATE app_settings SET value = '${process.env.APP_VERSION || '3.0.15'}' WHERE key = 'app_version'`);
}, },
// Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
// clients to skip the browser consent flow entirely and obtain tokens directly
// via client_id + client_secret. Flag is immutable after creation so existing
// authorization-code clients are not silently upgraded.
() => {
try { db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); }
catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
// Drop stale atlas cache rows for territories that used to resolve to their
// surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy,
// etc.) before their own bounding boxes existed. The next atlas stats request
// re-resolves any place inside these boxes with the corrected country code.
() => {
const enclaveBoxes: [number, number, number, number][] = [
[113.83, 22.15, 114.43, 22.56], // HK
[113.53, 22.10, 113.60, 22.21], // MO
[12.40, 43.89, 12.52, 43.99], // SM
[12.44, 41.90, 12.46, 41.91], // VA
[7.40, 43.72, 7.44, 43.75], // MC
[9.47, 47.05, 9.64, 47.27], // LI
[-5.36, 36.11, -5.33, 36.16], // GI
[-67.30, 17.88, -65.22, 18.53], // PR
];
try {
const del = db.prepare(
`DELETE FROM place_regions WHERE place_id IN (
SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?
)`
);
for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) {
del.run(minLat, maxLat, minLng, maxLng);
}
} catch (err: any) {
if (!err.message?.includes('no such table')) throw err;
}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+56 -5
View File
@@ -1,7 +1,16 @@
import 'reflect-metadata';
import 'dotenv/config'; import 'dotenv/config';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http';
import express from 'express';
import cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import type { INestApplication } from '@nestjs/common';
import { createApp } from './app'; import { createApp } from './app';
import { AppModule } from './nest/app.module';
import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler';
// Create upload and data directories on startup // Create upload and data directories on startup
const uploadsDir = path.join(__dirname, '../uploads'); const uploadsDir = path.join(__dirname, '../uploads');
@@ -16,7 +25,10 @@ const tmpDir = path.join(__dirname, '../data/tmp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}); });
const app = createApp(); // Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted
// in front of it (strangler pattern): migrated route prefixes are served by Nest,
// everything else falls through to this app via a fallback middleware.
const legacyApp = createApp();
import * as scheduler from './scheduler'; import * as scheduler from './scheduler';
import { getAppUrl, getMcpSafeUrl } from './services/notifications'; import { getAppUrl, getMcpSafeUrl } from './services/notifications';
@@ -49,6 +61,11 @@ const onListen = () => {
'──────────────────────────────────────', '──────────────────────────────────────',
]; ];
banner.forEach(l => console.log(l)); banner.forEach(l => console.log(l));
sLogInfo(
NEST_PREFIXES.length
? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)`
: 'NestJS prefixes: none — all routes served by the legacy Express app',
);
if (process.env.APP_URL) { if (process.env.APP_URL) {
let parsedAppUrl: URL | null = null; let parsedAppUrl: URL | null = null;
try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ } try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ }
@@ -84,9 +101,42 @@ const onListen = () => {
}); });
}; };
const server = HOST let server: http.Server;
? app.listen(PORT, HOST, onListen) let nestApp: INestApplication;
: app.listen(PORT, onListen);
// Strangler toggle: prefixes served by Nest (env-overridable, instant rollback).
const NEST_PREFIXES = getNestPrefixes();
const isNestPath = makeNestPathMatcher(NEST_PREFIXES);
async function bootstrap(): Promise<void> {
// Nest runs on its own Express instance (bodyParser off so request bodies reach
// the legacy app untouched — it has its own parsers; /mcp relies on raw body).
// Nest body parsing is safe here: the dispatcher only forwards migrated
// prefixes to this instance, so the legacy app (and raw-body routes like /mcp)
// is reached separately and never passes through Nest's parser.
nestApp = await NestFactory.create(AppModule, new ExpressAdapter());
// cookie-parser so the auth guard can read the existing `trek_session` cookie.
nestApp.use(cookieParser());
// (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.)
await nestApp.init();
const nestInstance = nestApp.getHttpAdapter().getInstance();
// Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy
// Express (unchanged). Nest never sees non-migrated paths, so its 404 handler
// only applies within migrated prefixes.
const top = express();
top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next()));
top.use(legacyApp);
server = http.createServer(top);
if (HOST) server.listen(PORT, HOST, onListen);
else server.listen(PORT, onListen);
}
bootstrap().catch((err) => {
console.error('Fatal: failed to bootstrap server', err);
process.exit(1);
});
// Graceful shutdown // Graceful shutdown
function shutdown(signal: string): void { function shutdown(signal: string): void {
@@ -95,6 +145,7 @@ function shutdown(signal: string): void {
sLogInfo(`${signal} received — shutting down gracefully...`); sLogInfo(`${signal} received — shutting down gracefully...`);
scheduler.stop(); scheduler.stop();
closeMcpSessions(); closeMcpSessions();
void nestApp?.close();
server.close(() => { server.close(() => {
sLogInfo('HTTP server closed'); sLogInfo('HTTP server closed');
const { closeDb } = require('./db/database'); const { closeDb } = require('./db/database');
@@ -111,4 +162,4 @@ function shutdown(signal: string): void {
process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGINT', () => shutdown('SIGINT'));
export default app; export default legacyApp;
+2 -1
View File
@@ -147,7 +147,8 @@ export const trekOAuthProvider: OAuthServerProvider = {
if (params.state) qs.set('state', params.state); if (params.state) qs.set('state', params.state);
if (params.resource) qs.set('resource', params.resource.href); if (params.resource) qs.set('resource', params.resource.href);
res.redirect(302, `/oauth/consent?${qs.toString()}`); const base = getMcpSafeUrl().replace(/\/+$/, '');
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
}, },
// Not called because skipLocalPkceValidation = true. // Not called because skipLocalPkceValidation = true.
+5 -3
View File
@@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
server.registerTool( server.registerTool(
'create_place_accommodation', 'create_place_accommodation',
{ {
description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.', description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
@@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
confirmation: z.string().max(100).optional(), confirmation: z.string().max(100).optional(),
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'), accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => { async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id); const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id);
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true }; if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try { try {
const run = db.transaction(() => { const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes }); const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation }; return { place, accommodation };
}); });
+10 -6
View File
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool( if (W) server.registerTool(
'create_place', 'create_place',
{ {
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.', description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
@@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => { async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency });
safeBroadcast(tripId, 'place:created', { place }); safeBroadcast(tripId, 'place:created', { place });
return ok({ place }); return ok({ place });
} }
@@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool( if (W) server.registerTool(
'create_and_assign_place', 'create_and_assign_place',
{ {
description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.', description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
dayId: z.number().int().positive().describe('Day to assign the place to'), dayId: z.number().int().positive().describe('Day to assign the place to'),
@@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'), assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => { async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try { try {
const run = db.transaction(() => { const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency });
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null); const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment }; return { place, assignment };
}); });
+18 -2
View File
@@ -6,6 +6,7 @@ import {
createReservation, getReservation, updateReservation, deleteReservation, createReservation, getReservation, updateReservation, deleteReservation,
updatePositions as updateReservationPositions, updatePositions as updateReservationPositions,
} from '../../services/reservationService'; } from '../../services/reservationService';
import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService'; import { getDay } from '../../services/dayService';
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService'; import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
import { import {
@@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop
server.registerTool( server.registerTool(
'create_reservation', 'create_reservation',
{ {
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.', description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
@@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'), check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'), assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'),
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => { async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined } ? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
: undefined; : undefined;
const metadata = price != null ? { price: String(price) } : undefined;
const { reservation, accommodationCreated } = createReservation(tripId, { const { reservation, accommodationCreated } = createReservation(tripId, {
title, type, reservation_time, location, confirmation_number, title, type, reservation_time, location, confirmation_number,
notes, day_id, place_id, assignment_id, notes, day_id, place_id, assignment_id,
create_accommodation: createAccommodation, create_accommodation: createAccommodation,
metadata,
}); });
if (accommodationCreated) { if (accommodationCreated) {
safeBroadcast(tripId, 'accommodation:created', {}); safeBroadcast(tripId, 'accommodation:created', {});
} }
if (price != null && price > 0) {
const item = linkBudgetItemToReservation(tripId, reservation.id, {
name: title,
category: budget_category || type,
total_price: price,
});
safeBroadcast(tripId, 'budget:created', { item });
}
safeBroadcast(tripId, 'reservation:created', { reservation }); safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation }); return ok({ reservation });
} }
+19 -3
View File
@@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService';
import { import {
createReservation, deleteReservation, getReservation, updateReservation, createReservation, deleteReservation, getReservation, updateReservation,
} from '../../services/reservationService'; } from '../../services/reservationService';
import { linkBudgetItemToReservation } from '../../services/budgetService';
import { getDay } from '../../services/dayService'; import { getDay } from '../../services/dayService';
import { import {
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
server.registerTool( server.registerTool(
'create_transport', 'create_transport',
{ {
description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.', description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']), type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
endpoints: endpointSchema, endpoints: endpointSchema,
needs_review: z.boolean().optional(), needs_review: z.boolean().optional(),
price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'),
budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => { async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
if (end_day_id && !getDay(end_day_id, tripId)) if (end_day_id && !getDay(end_day_id, tripId))
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
const meta: Record<string, string> = { ...(metadata ?? {}) };
if (price != null) meta.price = String(price);
const { reservation } = createReservation(tripId, { const { reservation } = createReservation(tripId, {
title, title,
type, type,
@@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes
day_id: start_day_id, day_id: start_day_id,
end_day_id: end_day_id ?? start_day_id, end_day_id: end_day_id ?? start_day_id,
status: status ?? 'pending', status: status ?? 'pending',
metadata, metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints, endpoints,
needs_review, needs_review,
}); });
if (price != null && price > 0) {
const item = linkBudgetItemToReservation(tripId, reservation.id, {
name: title,
category: budget_category || type,
total_price: price,
});
safeBroadcast(tripId, 'budget:created', { item });
}
safeBroadcast(tripId, 'reservation:created', { reservation }); safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation }); return ok({ reservation });
} }
+58
View File
@@ -0,0 +1,58 @@
# NestJS migration layer — module & test guide
This folder holds the co-hosted NestJS app that incrementally strangles the legacy
Express API (see the "Brownfield Rewrite" board). Until a prefix is migrated, the
top-level dispatcher in `src/index.ts` routes it to the legacy app; migrated
prefixes go to Nest. **Weather (`weather/`) is the reference implementation** — copy
its shape when migrating a new domain.
## Module layout (per domain)
```
shared/src/<domain>/<domain>.schema.ts(.spec.ts) # Zod contract — single source of truth
server/src/nest/<domain>/<domain>.service.ts # business logic (ported 1:1 from the Express service)
server/src/nest/<domain>/<domain>.controller.ts # same routes/verbs/params/status codes as Express
server/src/nest/<domain>/<domain>.module.ts # registered in app.module.ts
```
Add the prefix to `DEFAULT_NEST_PREFIXES` in `strangler.ts` to route it to Nest
(operators can override at runtime via the `NEST_PREFIXES` env var — instant
rollback, no redeploy).
## Parity is law
A migrated route must be **byte-identical** for the client: same URL, method,
query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error
strings. Where the legacy route returns a hand-written error (e.g. weather's
`{ error: 'Latitude and longitude are required' }`), reproduce that exact body in
the controller rather than relying on the generic `ZodValidationPipe` envelope.
## How to write the tests
Every module ships three kinds of tests; the coverage gate (`vitest.config.ts`,
scoped to `src/nest/**`) requires ≥80%.
1. **Service / controller unit spec**`tests/unit/nest/<domain>.controller.test.ts`.
Instantiate the controller with a mocked service; assert status codes, the exact
`{ error }` bodies, and that inputs are forwarded correctly (defaults, coercion).
See `weather.controller.test.ts`.
2. **Parity test**`tests/parity/<domain>.parity.test.ts`. Mock the shared service
identically for both apps, then fire the same request at the Express route and the
Nest controller with the `expectParity()` harness (`tests/parity/parity.ts`) and
assert identical status + body. This is the gate before flipping the toggle.
See `weather.parity.test.ts`.
3. **e2e**`tests/e2e/<domain>.e2e.test.ts`. Boot the Nest module against a temp
in-memory SQLite db via the shared harness (`tests/e2e/harness.ts`:
`createTempDb`/`seedUser`/`sessionCookie`), exercising the **real** `JwtAuthGuard`
end-to-end (401 without cookie, 200 with a signed session). Mock external I/O
(HTTP/etc.). See `weather.e2e.test.ts`.
## Definition of Done (per module)
Contract in `@trek/shared` → service ported 1:1 → controller with identical routes →
validation/error parity → unit + parity + e2e tests over the gate → prefix toggled to
Nest → parity verified on the demo DB → **then** decommission the old Express
route/service (separate step, after the toggle is confirmed in prod) → frontend points
at the typed contract (Frontend Track).
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { DatabaseModule } from './database/database.module';
import { HealthController } from './health/health.controller';
import { HealthService } from './health/health.service';
import { WeatherModule } from './weather/weather.module';
import { TrekExceptionFilter } from './common/trek-exception.filter';
/**
* Root NestJS module for the incremental migration. Domain modules
* (weather, notifications, ...) get registered here as they are migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule],
controllers: [HealthController],
providers: [
HealthService,
// Global error-envelope normaliser (DI-registered so it also catches
// framework-level exceptions like the not-found handler).
{ provide: APP_FILTER, useClass: TrekExceptionFilter },
],
})
export class AppModule {}
+19
View File
@@ -0,0 +1,19 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
/**
* Mirrors the legacy `adminOnly` middleware: requires an authenticated admin.
* Use together with JwtAuthGuard (which populates req.user):
* `@UseGuards(JwtAuthGuard, AdminGuard)`.
*/
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request & { user?: User }>();
if (!req.user || req.user.role !== 'admin') {
throw new HttpException({ error: 'Admin access required' }, 403);
}
return true;
}
}
@@ -0,0 +1,12 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { User } from '../../types';
/**
* Resolves the authenticated user attached by JwtAuthGuard.
* Use on guarded handlers: `getThing(@CurrentUser() user: User) { ... }`.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): User | undefined => {
return context.switchToHttp().getRequest().user;
},
);
+28
View File
@@ -0,0 +1,28 @@
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
import type { Request } from 'express';
import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth';
/**
* Validates TREK's existing JWT session the same httpOnly `trek_session`
* cookie (or `Authorization: Bearer`) the legacy app uses. Reuses the canonical
* `verifyJwtAndLoadUser` so the secret, the password_version invalidation gate
* and the loaded user are IDENTICAL to the Express middleware. No new tokens.
*
* Error bodies match the legacy 401 shape exactly so the client is unaffected.
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<Request>();
const token = extractToken(req);
if (!token) {
throw new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401);
}
const user = verifyJwtAndLoadUser(token);
if (!user) {
throw new HttpException({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }, 401);
}
(req as Request & { user?: unknown }).user = user;
return true;
}
}
@@ -0,0 +1,42 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { Response } from 'express';
/**
* Normalises every Nest exception to TREK's legacy error envelope so migrated
* routes are byte-identical for the client:
* - 4xx -> { error: <message> } (5xx -> { error: 'Internal server error' })
* - exceptions already throwing { error, code? } (e.g. the auth guards) pass through
* This replaces Nest's default { statusCode, message, error } body, which the
* TREK client does not expect.
*/
@Catch()
export class TrekExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const res = host.switchToHttp().getResponse<Response>();
if (exception instanceof HttpException) {
const status = exception.getStatus();
const body = exception.getResponse();
// Already in TREK shape (e.g. guards throw { error, code }): pass through.
if (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)) {
res.status(status).json(body);
return;
}
const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message;
const message =
status < 500
? Array.isArray(raw)
? raw.join(', ')
: String(raw ?? 'Error')
: 'Internal server error';
res.status(status).json({ error: message });
return;
}
// Unknown/unhandled error — mirror the legacy 500 behaviour.
console.error('Unhandled error:', exception);
res.status(500).json({ error: 'Internal server error' });
}
}
@@ -0,0 +1,26 @@
import { ArgumentMetadata, HttpException, Injectable, PipeTransform } from '@nestjs/common';
import type { ZodType } from 'zod';
/**
* Validates an incoming @Body()/@Query() against a Zod schema (from @trek/shared)
* and returns the parsed, typed value. On failure it throws TREK's error envelope
* `{ error: string }` with status 400 the same shape the legacy routes produce,
* so the client's error handling is unaffected.
*
* Usage: `@Body(new ZodValidationPipe(someSchema)) dto: Dto`.
*/
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodType) {}
transform(value: unknown, _metadata: ArgumentMetadata): unknown {
const result = this.schema.safeParse(value);
if (!result.success) {
const message = result.error.issues
.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`)
.join('; ');
throw new HttpException({ error: message }, 400);
}
return result.data;
}
}
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { DatabaseService } from './database.service';
/**
* Global so every migrated module can inject DatabaseService without re-importing.
* Wraps the existing better-sqlite3 singleton (no new connection).
*/
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import type Database from 'better-sqlite3';
import { db } from '../../db/database';
/**
* Injectable wrapper around TREK's existing better-sqlite3 connection.
*
* `db` is a Proxy onto the singleton connection the legacy app already uses
* (WAL enabled), so Nest modules share the exact same connection no second
* connection, no split state, single writer preserved.
*/
@Injectable()
export class DatabaseService {
/** The shared better-sqlite3 connection (same singleton the legacy app uses). */
get connection(): Database.Database {
return db;
}
prepare(sql: string): Database.Statement {
return db.prepare(sql);
}
get<T = unknown>(sql: string, ...params: unknown[]): T | undefined {
return db.prepare(sql).get(...params) as T | undefined;
}
all<T = unknown>(sql: string, ...params: unknown[]): T[] {
return db.prepare(sql).all(...params) as T[];
}
run(sql: string, ...params: unknown[]): Database.RunResult {
return db.prepare(sql).run(...params);
}
/** Run `fn` inside a synchronous better-sqlite3 transaction. */
transaction<T>(fn: (conn: Database.Database) => T): T {
return db.transaction(() => fn(db))();
}
}
@@ -0,0 +1,41 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import type { User } from '../../types';
import { HealthService } from './health.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
// Local demo schema (real domains import their schema from @trek/shared).
const echoSchema = z.object({ name: z.string().min(1) });
/**
* Foundation smoke endpoints for the co-hosted NestJS app.
* Proves: boot, routing, type-based DI, the shared SQLite connection, the
* JWT-cookie auth guard, and the Zod validation pipe + error-envelope parity.
*
* Lives under /api/_nest/* so it never collides with the legacy Express API.
*/
@Controller('api/_nest')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
@Get('health')
getHealth() {
return { ok: true, ...this.healthService.info() };
}
/** Guarded: returns the authenticated user, proving JwtAuthGuard + @CurrentUser. */
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: User) {
return user;
}
/** Validated: proves the Zod pipe (400 + { error } on failure) and body parsing. */
@Post('echo')
@UseGuards(JwtAuthGuard)
echo(@Body(new ZodValidationPipe(echoSchema)) body: z.infer<typeof echoSchema>) {
return { youSent: body };
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
/**
* Smoke service proving NestJS DI works under the chosen runtime AND that the
* injected DatabaseService talks to TREK's existing SQLite connection.
*/
@Injectable()
export class HealthService {
constructor(private readonly database: DatabaseService) {}
info() {
const row = this.database.get<{ n: number }>('SELECT COUNT(*) AS n FROM users');
return {
runtime: 'nestjs',
diInjected: true,
// Proof the shared connection works: real row count from the existing DB.
userCount: row?.n ?? null,
};
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Strangler toggle for the incremental NestJS migration.
*
* `getNestPrefixes()` returns the request path prefixes that NestJS handles;
* every other path falls through to the legacy Express app. The default is the
* set of prefixes whose Nest modules exist. Operators can override it at runtime
* via the `NEST_PREFIXES` env var (comma-separated) for instant Nest<->Express
* rollback no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes
* everything back to the legacy app.
*/
const DEFAULT_NEST_PREFIXES = ['/api/_nest', '/api/weather'];
export function getNestPrefixes(): string[] {
const raw = process.env.NEST_PREFIXES;
if (raw !== undefined) {
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
return DEFAULT_NEST_PREFIXES;
}
/** Builds a matcher: true when `path` belongs to one of the migrated prefixes. */
export function makeNestPathMatcher(prefixes: string[]): (path: string) => boolean {
return (path) => prefixes.some((prefix) => path === prefix || path.startsWith(prefix + '/'));
}
@@ -0,0 +1,66 @@
import { Controller, Get, HttpException, Query, UseGuards } from '@nestjs/common';
import type { WeatherResult } from '@trek/shared';
import { WeatherService } from './weather.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiError } from '../../services/weatherService';
/**
* /api/weather first migrated leaf module (the pilot).
*
* Behaviour is byte-identical to the legacy Express route (server/src/routes/
* weather.ts): same paths, query params, status codes and `{ error }` bodies.
*
* Parity note: the "X is required" 400s and the 500 fallback messages are bespoke
* strings, not the generic Zod-pipe envelope, so they are reproduced here exactly
* rather than derived from the schema. The Zod contract/types live in
* @trek/shared/weather and are used for typing; `lang` defaults to 'de' only when
* the param is absent, matching the Express destructuring default.
*/
@Controller('api/weather')
@UseGuards(JwtAuthGuard)
export class WeatherController {
constructor(private readonly weather: WeatherService) {}
@Get()
async getWeather(
@Query('lat') lat?: string,
@Query('lng') lng?: string,
@Query('date') date?: string,
@Query('lang') lang?: string,
): Promise<WeatherResult> {
if (!lat || !lng) {
throw new HttpException({ error: 'Latitude and longitude are required' }, 400);
}
try {
return await this.weather.get(lat, lng, date, lang ?? 'de');
} catch (err: unknown) {
throw toHttp(err, 'Weather error:', 'Error fetching weather data');
}
}
@Get('detailed')
async getDetailed(
@Query('lat') lat?: string,
@Query('lng') lng?: string,
@Query('date') date?: string,
@Query('lang') lang?: string,
): Promise<WeatherResult> {
if (!lat || !lng || !date) {
throw new HttpException({ error: 'Latitude, longitude, and date are required' }, 400);
}
try {
return await this.weather.getDetailed(lat, lng, date, lang ?? 'de');
} catch (err: unknown) {
throw toHttp(err, 'Detailed weather error:', 'Error fetching detailed weather data');
}
}
}
/** Maps a thrown error to the same status + `{ error }` body the Express route sent. */
function toHttp(err: unknown, logPrefix: string, fallback: string): HttpException {
if (err instanceof ApiError) {
return new HttpException({ error: err.message }, err.status);
}
console.error(logPrefix, err);
return new HttpException({ error: fallback }, 500);
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WeatherController } from './weather.controller';
import { WeatherService } from './weather.service';
/** Weather domain (pilot leaf module). Registered in AppModule. */
@Module({
controllers: [WeatherController],
providers: [WeatherService],
})
export class WeatherModule {}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import type { WeatherResult } from '@trek/shared';
import { getWeather, getDetailedWeather } from '../../services/weatherService';
/**
* Thin Nest wrapper around the existing weather service. It delegates to the
* exact same `getWeather` / `getDetailedWeather` functions the legacy route and
* the MCP tools use, so behaviour including the shared in-memory cache and the
* Open-Meteo calls is identical. No logic is duplicated; the upstream service
* stays the single source of truth (still consumed by the MCP weather tools).
*/
@Injectable()
export class WeatherService {
get(lat: string, lng: string, date: string | undefined, lang: string): Promise<WeatherResult> {
return getWeather(lat, lng, date, lang) as Promise<WeatherResult>;
}
getDetailed(lat: string, lng: string, date: string, lang: string): Promise<WeatherResult> {
return getDetailedWeather(lat, lng, date, lang) as Promise<WeatherResult>;
}
}
+2 -2
View File
@@ -98,7 +98,7 @@ router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) =
// ── Photos (prefix /photos and /entries — before /:id) ─────────────────── // ── Photos (prefix /photos and /entries — before /:id) ───────────────────
router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), async (req: Request, res: Response) => { router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[]; const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
@@ -201,7 +201,7 @@ router.delete('/photos/:photoId', authenticate, async (req: Request, res: Respon
// ── Gallery (prefix /:id/gallery — before /:id) ────────────────────────── // ── Gallery (prefix /:id/gallery — before /:id) ──────────────────────────
// Upload photos directly to the journey gallery (no entry association) // Upload photos directly to the journey gallery (no entry association)
router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), async (req: Request, res: Response) => { router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest; const authReq = req as AuthRequest;
const files = req.files as Express.Multer.File[]; const files = req.files as Express.Multer.File[];
if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); if (!files?.length) return res.status(400).json({ error: 'No files uploaded' });
+48 -3
View File
@@ -10,6 +10,7 @@ import {
consumeAuthCode, consumeAuthCode,
saveConsent, saveConsent,
issueTokens, issueTokens,
issueClientCredentialsToken,
refreshTokens, refreshTokens,
revokeToken, revokeToken,
verifyPKCE, verifyPKCE,
@@ -24,6 +25,7 @@ import {
AuthorizeParams, AuthorizeParams,
} from '../services/oauthService'; } from '../services/oauthService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog'; import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
import { getMcpSafeUrl } from '../services/notifications';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Minimal in-file rate limiter (same pattern as auth.ts) // Minimal in-file rate limiter (same pattern as auth.ts)
@@ -151,6 +153,48 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.json(result.tokens); return res.json(result.tokens);
} }
// ---- client_credentials grant ----
if (grant_type === 'client_credentials') {
if (!client_secret) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
}
const client = authenticateClient(client_id, client_secret);
if (!client) {
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
// Public clients and DCR-anonymous clients are ineligible for client_credentials.
if (client.is_public || !client.allows_client_credentials || client.user_id == null) {
writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip });
return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' });
}
// Scope: use requested subset or fall back to all allowed scopes.
const allowedScopes: string[] = JSON.parse(client.allowed_scopes);
let grantedScopes: string[];
if (body.scope) {
const requested = body.scope.split(' ').filter(Boolean);
const invalid = requested.filter(s => !allowedScopes.includes(s));
if (invalid.length > 0) {
return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` });
}
grantedScopes = requested;
} else {
grantedScopes = allowedScopes;
}
// Audience: honour RFC 8707 resource param; default to the MCP endpoint so the
// token passes audience binding in mcp/index.ts without extra configuration.
const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`;
const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
return res.json(tokens);
}
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` }); return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
}); });
@@ -327,13 +371,14 @@ oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => {
oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => { oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' }); if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' });
const { user } = req as AuthRequest; const { user } = req as AuthRequest;
const { name, redirect_uris, allowed_scopes } = req.body as { const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as {
name: string; name: string;
redirect_uris: string[]; redirect_uris?: string[];
allowed_scopes: string[]; allowed_scopes: string[];
allows_client_credentials?: boolean;
}; };
const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req)); const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials });
if (result.error) return res.status(result.status || 400).json({ error: result.error }); if (result.error) return res.status(result.status || 400).json({ error: result.error });
return res.status(201).json(result); return res.status(201).json(result);
}); });
+2 -4
View File
@@ -13,7 +13,7 @@ import {
updateReservation, updateReservation,
deleteReservation, deleteReservation,
} from '../services/reservationService'; } from '../services/reservationService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService'; import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
@@ -55,13 +55,11 @@ router.post('/', authenticate, (req: Request, res: Response) => {
// Auto-create budget entry if price was provided // Auto-create budget entry if price was provided
if (create_budget_entry && create_budget_entry.total_price > 0) { if (create_budget_entry && create_budget_entry.total_price > 0) {
try { try {
const budgetItem = createBudgetItem(tripId, { const budgetItem = linkBudgetItemToReservation(tripId, reservation.id, {
name: title, name: title,
category: create_budget_entry.category || type || 'Other', category: create_budget_entry.category || type || 'Other',
total_price: create_budget_entry.total_price, total_price: create_budget_entry.total_price,
}); });
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservation.id, budgetItem.id);
budgetItem.reservation_id = reservation.id;
broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string); broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string);
} catch (err) { } catch (err) {
console.error('[reservations] Failed to create budget entry:', err); console.error('[reservations] Failed to create budget entry:', err);
-45
View File
@@ -1,45 +0,0 @@
import express, { Request, Response } from 'express';
import { authenticate } from '../middleware/auth';
import { getWeather, getDetailedWeather, ApiError } from '../services/weatherService';
const router = express.Router();
router.get('/', authenticate, async (req: Request, res: Response) => {
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date?: string; lang?: string };
if (!lat || !lng) {
return res.status(400).json({ error: 'Latitude and longitude are required' });
}
try {
const result = await getWeather(lat, lng, date, lang);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
return res.status(err.status).json({ error: err.message });
}
console.error('Weather error:', err);
res.status(500).json({ error: 'Error fetching weather data' });
}
});
router.get('/detailed', authenticate, async (req: Request, res: Response) => {
const { lat, lng, date, lang = 'de' } = req.query as { lat: string; lng: string; date: string; lang?: string };
if (!lat || !lng || !date) {
return res.status(400).json({ error: 'Latitude, longitude, and date are required' });
}
try {
const result = await getDetailedWeather(lat, lng, date, lang);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
return res.status(err.status).json({ error: err.message });
}
console.error('Detailed weather error:', err);
res.status(500).json({ error: 'Error fetching detailed weather data' });
}
});
export default router;
+20 -4
View File
@@ -100,6 +100,12 @@ export const COUNTRY_BOXES: Record<string, [number, number, number, number]> = {
UG:[29.6,-1.5,35.0,4.2],UY:[-58.4,-34.9,-53.1,-30.1],UZ:[55.9,37.2,73.1,45.6],VE:[-73.4,0.7,-59.8,12.2], UG:[29.6,-1.5,35.0,4.2],UY:[-58.4,-34.9,-53.1,-30.1],UZ:[55.9,37.2,73.1,45.6],VE:[-73.4,0.7,-59.8,12.2],
AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],XK:[20.0,41.9,21.8,43.3], AE:[51.6,22.6,56.4,26.1],GB:[-8,49.9,2,60.9],US:[-125,24.5,-66.9,49.4],VN:[102.1,8.6,109.5,23.4],XK:[20.0,41.9,21.8,43.3],
YE:[42.5,12.1,54.0,19.0],ZM:[21.9,-18.1,33.7,-8.2],ZW:[25.2,-22.4,33.1,-15.6], YE:[42.5,12.1,54.0,19.0],ZM:[21.9,-18.1,33.7,-8.2],ZW:[25.2,-22.4,33.1,-15.6],
// Territories with their own ISO code that sit inside a larger country's box.
// Listed so getCountryFromCoords()'s smallest-box match picks them over the host
// (e.g. Hong Kong/Macau over China, San Marino/Vatican over Italy).
HK:[113.83,22.15,114.43,22.56],MO:[113.53,22.10,113.60,22.21],SM:[12.40,43.89,12.52,43.99],
VA:[12.44,41.90,12.46,41.91],MC:[7.40,43.72,7.44,43.75],LI:[9.47,47.05,9.64,47.27],
GI:[-5.36,36.11,-5.33,36.16],PR:[-67.30,17.88,-65.22,18.53],
}; };
export const NAME_TO_CODE: Record<string, string> = { export const NAME_TO_CODE: Record<string, string> = {
@@ -144,6 +150,9 @@ export const NAME_TO_CODE: Record<string, string> = {
'angola':'AO','namibia':'NA','botswana':'BW','zimbabwe':'ZW','zambia':'ZM','malawi':'MW', 'angola':'AO','namibia':'NA','botswana':'BW','zimbabwe':'ZW','zambia':'ZM','malawi':'MW',
'mozambique':'MZ','mozambik':'MZ','madagascar':'MG','rwanda':'RW','burundi':'BI', 'mozambique':'MZ','mozambik':'MZ','madagascar':'MG','rwanda':'RW','burundi':'BI',
'somalia':'SO','papua new guinea':'PG','brunei':'BN', 'somalia':'SO','papua new guinea':'PG','brunei':'BN',
'hong kong':'HK','hong kong sar':'HK','macau':'MO','macao':'MO','macau sar':'MO',
'san marino':'SM','vatican':'VA','vatican city':'VA','holy see':'VA','monaco':'MC',
'liechtenstein':'LI','gibraltar':'GI','puerto rico':'PR',
}; };
export const CONTINENT_MAP: Record<string, string> = { export const CONTINENT_MAP: Record<string, string> = {
@@ -167,6 +176,7 @@ export const CONTINENT_MAP: Record<string, string> = {
ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America', ZA:'Africa',SE:'Europe',CH:'Europe',TH:'Asia',TR:'Europe',UA:'Europe',UG:'Africa',UY:'South America',
UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe', UZ:'Asia',VE:'South America',AE:'Asia',GB:'Europe',US:'North America',VN:'Asia',XK:'Europe',
YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa', YE:'Asia',ZM:'Africa',ZW:'Africa',NG:'Africa',
HK:'Asia',MO:'Asia',SM:'Europe',VA:'Europe',MC:'Europe',LI:'Europe',GI:'Europe',PR:'North America',
}; };
// ── Geocoding helpers ─────────────────────────────────────────────────────── // ── Geocoding helpers ───────────────────────────────────────────────────────
@@ -366,11 +376,17 @@ export async function getStats(userId: number) {
for (const place of places) { for (const place of places) {
if (place.address) { if (place.address) {
const parts = place.address.split(',').map((s: string) => s.trim()).filter(Boolean); const parts = place.address.split(',').map((s: string) => s.trim()).filter(Boolean);
let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0]; // The last part is the country; the city is usually right before it, but a
if (raw) { // full formatted address can have a postal code sitting between them
const city = raw.replace(/[\d\-\u2212\u3012]+/g, '').trim().toLowerCase(); // (e.g. "Bucharest, 010071, Romania"). Walk back from the country and take
if (city) citySet.add(city); // the first part that still has letters once digits/postal noise is stripped.
const candidates = parts.length >= 2 ? parts.slice(0, -1) : parts;
let city = '';
for (let i = candidates.length - 1; i >= 0; i--) {
const cleaned = candidates[i].replace(/[\d\-\u2212\u3012]+/g, '').trim();
if (cleaned) { city = cleaned.toLowerCase(); break; }
} }
if (city) citySet.add(city);
} }
} }
const totalCities = citySet.size; const totalCities = citySet.size;
+11
View File
@@ -96,6 +96,17 @@ export function createBudgetItem(
return item; return item;
} }
export function linkBudgetItemToReservation(
tripId: string | number,
reservationId: number,
data: { name: string; category?: string; total_price: number },
) {
const item = createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null };
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id);
item.reservation_id = reservationId;
return item;
}
export function updateBudgetItem( export function updateBudgetItem(
id: string | number, id: string | number,
tripId: string | number, tripId: string | number,
+6 -1
View File
@@ -1,6 +1,7 @@
import { db } from '../db/database'; import { db } from '../db/database';
import { decrypt_api_key } from './apiKeyCrypto'; import { decrypt_api_key } from './apiKeyCrypto';
import { checkSsrf } from '../utils/ssrfGuard'; import { checkSsrf } from '../utils/ssrfGuard';
import { getAppUrl } from './notifications';
// ── Google API call counter ─────────────────────────────────────────────────── // ── Google API call counter ───────────────────────────────────────────────────
@@ -12,7 +13,11 @@ export function resetGoogleApiCallCount(): void { googleApiCallCount = 0; }
function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> { function googleFetch(endpoint: string, label: string, init?: RequestInit): Promise<Response> {
googleApiCallCount++; googleApiCallCount++;
console.debug(`[Google API] #${googleApiCallCount} ${label}${endpoint}`); console.debug(`[Google API] #${googleApiCallCount} ${label}${endpoint}`);
return fetch(endpoint, init); const referer = process.env.APP_URL ? getAppUrl() : undefined;
return fetch(endpoint, {
...init,
headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record<string, string> ?? {}) },
});
} }
// ── Interfaces ─────────────────────────────────────────────────────────────── // ── Interfaces ───────────────────────────────────────────────────────────────
+3 -3
View File
@@ -316,12 +316,12 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
// ── Email HTML builder ───────────────────────────────────────────────────── // ── Email HTML builder ─────────────────────────────────────────────────────
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): string { export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
const s = I18N[lang] || I18N.en; const s = I18N[lang] || I18N.en;
const appUrl = getAppUrl(); const appUrl = getAppUrl();
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || '')); const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
const safeSubject = escapeHtml(subject); const safeSubject = escapeHtml(subject);
const safeBody = escapeHtml(body); const safeBody = rawBody ? body : escapeHtml(body);
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
@@ -396,7 +396,7 @@ function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings,
<p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p> <p style="margin:0 0 10px 0; font-size:13px; color:#6B7280;">${safeExpiry}</p>
<p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p> <p style="margin:0; font-size:13px; color:#6B7280;">${safeIgnore}</p>
`; `;
return buildEmailHtml(subject, block, lang); return buildEmailHtml(subject, block, lang, undefined, true);
} }
/** /**
+50 -8
View File
@@ -60,6 +60,7 @@ interface OAuthClientRow {
created_at: string; created_at: string;
is_public: number; // 0 | 1 (SQLite boolean) is_public: number; // 0 | 1 (SQLite boolean)
created_via: string; // 'settings_ui' | 'browser-registration' created_via: string; // 'settings_ui' | 'browser-registration'
allows_client_credentials: number; // 0 | 1
} }
interface OAuthTokenRow { interface OAuthTokenRow {
@@ -106,11 +107,12 @@ function generateRefreshToken(): string {
export function listOAuthClients(userId: number): Record<string, unknown>[] { export function listOAuthClients(userId: number): Record<string, unknown>[] {
const rows = db.prepare( const rows = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC' 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as OAuthClientRow[]; ).all(userId) as OAuthClientRow[];
return rows.map(r => ({ return rows.map(r => ({
...r, ...r,
is_public: Boolean(r.is_public), is_public: Boolean(r.is_public),
allows_client_credentials: Boolean(r.allows_client_credentials),
redirect_uris: JSON.parse(r.redirect_uris), redirect_uris: JSON.parse(r.redirect_uris),
allowed_scopes: JSON.parse(r.allowed_scopes), allowed_scopes: JSON.parse(r.allowed_scopes),
})); }));
@@ -132,11 +134,12 @@ export function createOAuthClient(
redirectUris: string[], redirectUris: string[],
allowedScopes: string[], allowedScopes: string[],
ip?: string | null, ip?: string | null,
options?: { isPublic?: boolean; createdVia?: string }, options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean },
): { error?: string; status?: number; client?: Record<string, unknown> } { ): { error?: string; status?: number; client?: Record<string, unknown> } {
if (!name?.trim()) return { error: 'Name is required', status: 400 }; if (!name?.trim()) return { error: 'Name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 }; if (name.trim().length > 100) return { error: 'Name must be 100 characters or less', status: 400 };
if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 }; const isMachineClient = Boolean(options?.allowsClientCredentials);
if (!isMachineClient && (!redirectUris || redirectUris.length === 0)) return { error: 'At least one redirect URI is required', status: 400 };
if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 }; if (redirectUris.length > 10) return { error: 'Maximum 10 redirect URIs per client', status: 400 };
for (const uri of redirectUris) { for (const uri of redirectUris) {
@@ -164,7 +167,8 @@ export function createOAuthClient(
if (count >= 500) return { error: 'server_error', status: 503 }; if (count >= 500) return { error: 'server_error', status: 503 };
} }
const isPublic = options?.isPublic ?? false; // Machine clients (client_credentials) must always be confidential — ignore isPublic for them.
const isPublic = isMachineClient ? false : (options?.isPublic ?? false);
const createdVia = options?.createdVia ?? 'settings_ui'; const createdVia = options?.createdVia ?? 'settings_ui';
const id = randomUUID(); const id = randomUUID();
const clientId = randomUUID(); const clientId = randomUUID();
@@ -173,14 +177,14 @@ export function createOAuthClient(
const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex'); const secretHash = rawSecret ? hashToken(rawSecret) : randomBytes(32).toString('hex');
db.prepare( db.prepare(
'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia); ).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0);
const row = db.prepare( const row = db.prepare(
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients WHERE id = ?' 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE id = ?'
).get(id) as OAuthClientRow; ).get(id) as OAuthClientRow;
writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip }); writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic, allows_client_credentials: isMachineClient }, ip });
return { return {
client: { client: {
@@ -192,6 +196,7 @@ export function createOAuthClient(
allowed_scopes: JSON.parse(row.allowed_scopes), allowed_scopes: JSON.parse(row.allowed_scopes),
created_at: row.created_at, created_at: row.created_at,
is_public: Boolean(row.is_public), is_public: Boolean(row.is_public),
allows_client_credentials: Boolean(row.allows_client_credentials),
created_via: row.created_via, created_via: row.created_via,
// client_secret only present for confidential clients — shown once, not stored in plain text // client_secret only present for confidential clients — shown once, not stored in plain text
...(rawSecret ? { client_secret: rawSecret } : {}), ...(rawSecret ? { client_secret: rawSecret } : {}),
@@ -330,6 +335,43 @@ export function issueTokens(
}; };
} }
// Issues an access token only — no refresh token (RFC 6749 §4.4.3).
// Used exclusively for the client_credentials grant. A random opaque hash is
// stored in refresh_token_hash to satisfy the NOT NULL/UNIQUE constraint; it
// can never be presented as a valid refresh token (same precedent as public
// client secret hashes stored in client_secret_hash).
export function issueClientCredentialsToken(
clientId: string,
userId: number,
scopes: string[],
audience: string,
): {
access_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
} {
const rawAccess = generateAccessToken();
const accessHash = hashToken(rawAccess);
const placeholderHash = randomBytes(32).toString('hex');
const now = new Date();
const accessExpiry = new Date(now.getTime() + ACCESS_TOKEN_TTL_S * 1000);
db.prepare(`
INSERT INTO oauth_tokens
(client_id, user_id, access_token_hash, refresh_token_hash, scopes, audience, access_token_expires_at, refresh_token_expires_at, parent_token_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(clientId, userId, accessHash, placeholderHash, JSON.stringify(scopes), audience, accessExpiry.toISOString(), now.toISOString(), null);
return {
access_token: rawAccess,
token_type: 'Bearer',
expires_in: ACCESS_TOKEN_TTL_S,
scope: scopes.join(' '),
};
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Token verification (used by MCP handler on every request) // Token verification (used by MCP handler on every request)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

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