Compare commits

..

11 Commits

Author SHA1 Message Date
sss3978 b8019dfc8c Update ja.ts 2026-05-14 17:22:21 +09:00
sss3978 fde4cc752c Merge remote-tracking branch 'upstream/dev' into dev 2026-05-14 08:00:46 +00:00
sss3978 7498a3142f Merge branch 'mauriceboe:dev' into dev 2026-05-07 16:46:43 +09:00
Julien G. 499097fa3c align dev (#899)
* chore: bump version to 3.0.0 [skip ci]

* fix: resolve dead wiki links across install and config pages

* fix(reservations): restore correct day assignment for non-transport bookings

v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.

- Migration 122: for every non-hotel reservation, null out any
  day_id / end_day_id that does not match the reservation's time,
  then backfill it from reservation_time / reservation_end_time.
  Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
  derive day_id / end_day_id from reservation_time /
  reservation_end_time when the client didn't send one explicitly,
  so the mismatch cannot reappear on new or edited bookings.
  Hotels are skipped because they store their date range on the
  linked day_accommodation.

* chore: bump version to 3.0.1 [skip ci]

* fix(oidc): normalize discovery doc issuer before comparison

Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.

Closes #834

* test(systemNotices): exclude v3 upgrade notices from login_count-only tests

Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.

* chore: bump version to 3.0.2 [skip ci]

* fix(oidc): normalize id_token iss claim before issuer comparison (#837)

jwt.verify does an exact string match on the issuer. Providers like
Authentik include a trailing slash in the id_token iss claim while the
configured issuer is already normalized (no trailing slash), causing
every login attempt to fail with jwt issuer invalid.

Move the issuer check out of jwt.verify options and apply the same
trailing-slash normalization used in the discovery doc validation.
Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing
slash, wrong issuer, and wrong audience cases.

Closes #834

* chore: bump version to 3.0.3 [skip ci]

* fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845)

OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery
doc's issuer for id_token comparison instead of rejecting a path
mismatch as an error. Authentik (and similar realm-path providers)
return a canonical issuer like /application/o/<slug>/ that differs
from the operator's base OIDC_ISSUER. Strict equality blocked login
in 3.x despite working in v2. Default discovery (no custom URL) keeps
the strict check. Adds OIDC-SVC-037/038/039.

UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h
paddingBottom offset that other overlays already use. On mobile portrait
the action buttons were hidden behind the sticky bottom nav bar.

Closes #843
Closes #844

* chore: bump version to 3.0.4 [skip ci]

* fix(files): open attachments only in new tab (#840)

window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead.

* chore: bump version to 3.0.5 [skip ci]

* fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848)

* fix(journey): make sort_order authoritative for within-day entry ordering

Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.

- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019

Closes #846

* fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847)

Reservations were matched to days by pickup date only, so the end-day
card (e.g. car Return, flight Arrival) was silently dropped from the PDF.
Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id
span, show reservation_end_time on end days, prefix title with phase label
(Return/Arrival/etc.), and use per-day position for sort order.

* test(pdf): add missing day_id to transport reservation fixture

* chore: bump version to 3.0.6 [skip ci]

* [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849)

* fix: server/package.json & server/package-lock.json to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-UUID-16133035

* fix: bump fast-xml-parser version

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: jubnl <jgunther021@gmail.com>

* chore: bump version to 3.0.7 [skip ci]

* fix: hot fixes 23-04-2026 (#856)

* fix(packing): resolve avatar URL path in bag and category assignees (#854)

packingService was returning raw avatar filenames from the DB instead of
the full /uploads/avatars/<filename> path, causing broken profile images
for users with uploaded avatars.

* fix(budget): use Map.get() to fix category rename no-op (#855)

* fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863)

- Change Helmet default from no-referrer to strict-origin-when-cross-origin
  so browsers send the origin on cross-origin requests, allowing Google Maps
  API key restrictions by HTTP referrer to work correctly
- Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts:
  .env.example, docker-compose.yml, README.md, unraid-template.xml,
  charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md

* fix(planner): prefetch budget items on trip page mount (#864)

Loads budgetItems alongside reservations when TripPlannerPage mounts so
the Budget category dropdown in ReservationModal and TransportModal shows
pre-existing categories on first open, regardless of whether the Budget
tab has been visited.

Closes #861

* fix(reservations): prevent Invalid Date when end time is set without end date (#866)

When reservation_end_time held a bare time string ("HH:MM"), fmtDate()
produced Invalid Date on the reservation card.

- Modal: when end date is blank but end time is filled, construct a
  same-day ISO datetime using the start date (prevents time-only strings
  from ever being persisted)
- Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD")
  still show the multi-day range, while bare time strings are skipped and
  handled correctly by the existing time column logic

Closes #860

* fix(planner): format reservation end time instead of rendering raw ISO string (#867)

Closes #859

* fix(planner): wire Route toggle into mobile day sidebar (#850) (#868)

The per-booking Route icon was missing on mobile because the mobile
DayPlanSidebar invocation in TripPlannerPage didn't pass
visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't
activate reservation map overlays without forcing desktop mode.

Also corrects the Map-Features wiki: fixes the setting name
("Booking route labels" not "Show connection labels"), documents the
route_calculation requirement for travel-time pills, and explains that
overlays are off by default and must be toggled per reservation.

* chore: bump version to 3.0.8 [skip ci]

* docs(wiki): add MCP OAuth troubleshooting entry for missing APP_URL

* Fix demo banner overlapping bottom tab bar on mobile

The demo welcome modal extended below the mobile bottom tab bar,
hiding the dismiss button so visitors couldn't close it.

- Use dvh so mobile URL bar is accounted for correctly
- Reserve ~80px of bottom padding for the tab bar
- Make the footer sticky so the dismiss button stays visible
  while scrolling through the modal content
- Bump z-index to ensure the overlay sits above the tab bar

* Fix 500 on reservation edit after DB reinit (issue #883)

saveEndpoints was bound at module load via db.transaction(...). When the
demo-mode hourly reset (or a self-hoster's backup restore) closes the DB
connection and reinitialises it, the bound transaction still references
the now-closed connection — every subsequent reservation save with an
endpoints field throws "The database connection is not open", which the
client surfaces as "Internal server error".

Bind the transaction lazily on each call so it always runs against the
current connection.

* Fix exit code 132 on old CPUs by replacing sharp with jimp (issue #888) (#895)

sharp's prebuilt Linux x64 binary requires SSE4.2 (x86-64-v2), causing a
SIGILL crash on older hardware (e.g. AMD A6-3420M). Replace with jimp, a
pure-JS image library with no native binaries. Also skip thumbnail generation
entirely when the Journey addon is disabled (the default), preventing the
issue for most installs regardless of the image library used.

* chore: Add Trademark policy

* chore: Add Trademark policy

* chore: bump version to 3.0.9 [skip ci]

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: Xre0uS <36565320+Xre0uS@users.noreply.github.com>
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2026-04-26 23:18:22 +02:00
Julien G. 002ea91be8 Align dev (#869)
* chore: bump version to 3.0.0 [skip ci]

* fix: resolve dead wiki links across install and config pages

* fix(reservations): restore correct day assignment for non-transport bookings

v3.0.0 switched the planner from rendering reservations by
reservation_time to rendering them by day_id (commit 3f61e1c), but
migration 110 only backfilled day_id for transport types. Tours,
restaurants, events and 'other' bookings kept whatever day_id was
stored in the DB — often the trip's first day, from older code paths
that defaulted it there — so after the upgrade those rows all show
up on day 1 regardless of their actual reservation_time.

- Migration 122: for every non-hotel reservation, null out any
  day_id / end_day_id that does not match the reservation's time,
  then backfill it from reservation_time / reservation_end_time.
  Idempotent; leaves already-correct rows alone.
- reservationService.createReservation / updateReservation now
  derive day_id / end_day_id from reservation_time /
  reservation_end_time when the client didn't send one explicitly,
  so the mismatch cannot reappear on new or edited bookings.
  Hotels are skipped because they store their date range on the
  linked day_accommodation.

* chore: bump version to 3.0.1 [skip ci]

* fix(oidc): normalize discovery doc issuer before comparison

Trailing slash in doc.issuer (e.g. Authentik) caused a mismatch against
the already-normalized configured issuer, breaking OIDC login entirely.

Closes #834

* test(systemNotices): exclude v3 upgrade notices from login_count-only tests

Tests that expect an empty notice list were using first_seen_version='0.0.0'
(DB default), which matches the existingUserBeforeVersion('3.0.0') condition
now that the app is at 3.0.1. Set first_seen_version='3.0.0' so only the
firstLogin condition controls visibility in these tests.

* chore: bump version to 3.0.2 [skip ci]

* fix(oidc): normalize id_token iss claim before issuer comparison (#837)

jwt.verify does an exact string match on the issuer. Providers like
Authentik include a trailing slash in the id_token iss claim while the
configured issuer is already normalized (no trailing slash), causing
every login attempt to fail with jwt issuer invalid.

Move the issuer check out of jwt.verify options and apply the same
trailing-slash normalization used in the discovery doc validation.
Also adds OIDC-SVC-033–036 unit tests covering exact match, trailing
slash, wrong issuer, and wrong audience cases.

Closes #834

* chore: bump version to 3.0.3 [skip ci]

* fix(oidc,ui): restore Authentik login and fix mobile delete dialog (#845)

OIDC: when OIDC_DISCOVERY_URL is explicitly set, trust the discovery
doc's issuer for id_token comparison instead of rejecting a path
mismatch as an error. Authentik (and similar realm-path providers)
return a canonical issuer like /application/o/<slug>/ that differs
from the operator's base OIDC_ISSUER. Strict equality blocked login
in 3.x despite working in v2. Default discovery (no custom URL) keeps
the strict check. Adds OIDC-SVC-037/038/039.

UI: ConfirmDialog and CopyTripDialog lacked the --bottom-nav-h
paddingBottom offset that other overlays already use. On mobile portrait
the action buttons were hidden behind the sticky bottom nav bar.

Closes #843
Closes #844

* chore: bump version to 3.0.4 [skip ci]

* fix(files): open attachments only in new tab (#840)

window.open with noreferrer returns null, which triggered the popup-blocked download fallback in addition to the new-tab open. Use a target=_blank anchor click instead.

* chore: bump version to 3.0.5 [skip ci]

* fix(journey,pdf): journey reorder sort_order + PDF multi-day transport (#848)

* fix(journey): make sort_order authoritative for within-day entry ordering

Reorder buttons appeared broken because the server ORDER BY put entry_time
before sort_order, so entries synced from trip places with differing times
would always sort by time regardless of sort_order writes. The client store
mirrored the same comparator, making even the optimistic update invisible.

- Change ORDER BY to (entry_date, sort_order, id) in getJourneyFull and listEntries
- Fix syncTripPlaces and onPlaceCreated to assign MAX+1 sort_order per day instead of day_number/0
- Update client store comparator to match
- Add DB migration to backfill sort_order using old effective key (entry_time, id) so existing journeys retain their visual order
- Add tests: JOURNEY-SVC-089–093, FE-STORE-JOURNEY-018–019

Closes #846

* fix(pdf): include multi-day transport return/arrival in PDF itinerary (#847)

Reservations were matched to days by pickup date only, so the end-day
card (e.g. car Return, flight Arrival) was silently dropped from the PDF.
Add span-aware helpers mirroring DayPlanSidebar logic: match by day_id/end_day_id
span, show reservation_end_time on end days, prefix title with phase label
(Return/Arrival/etc.), and use per-day position for sort order.

* test(pdf): add missing day_id to transport reservation fixture

* chore: bump version to 3.0.6 [skip ci]

* [Snyk] Security upgrade uuid from 9.0.1 to 14.0.0 (#849)

* fix: server/package.json & server/package-lock.json to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-UUID-16133035

* fix: bump fast-xml-parser version

---------

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: jubnl <jgunther021@gmail.com>

* chore: bump version to 3.0.7 [skip ci]

* fix: hot fixes 23-04-2026 (#856)

* fix(packing): resolve avatar URL path in bag and category assignees (#854)

packingService was returning raw avatar filenames from the DB instead of
the full /uploads/avatars/<filename> path, causing broken profile images
for users with uploaded avatars.

* fix(budget): use Map.get() to fix category rename no-op (#855)

* fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863)

- Change Helmet default from no-referrer to strict-origin-when-cross-origin
  so browsers send the origin on cross-origin requests, allowing Google Maps
  API key restrictions by HTTP referrer to work correctly
- Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts:
  .env.example, docker-compose.yml, README.md, unraid-template.xml,
  charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md

* fix(planner): prefetch budget items on trip page mount (#864)

Loads budgetItems alongside reservations when TripPlannerPage mounts so
the Budget category dropdown in ReservationModal and TransportModal shows
pre-existing categories on first open, regardless of whether the Budget
tab has been visited.

Closes #861

* fix(reservations): prevent Invalid Date when end time is set without end date (#866)

When reservation_end_time held a bare time string ("HH:MM"), fmtDate()
produced Invalid Date on the reservation card.

- Modal: when end date is blank but end time is filled, construct a
  same-day ISO datetime using the start date (prevents time-only strings
  from ever being persisted)
- Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD")
  still show the multi-day range, while bare time strings are skipped and
  handled correctly by the existing time column logic

Closes #860

* fix(planner): format reservation end time instead of rendering raw ISO string (#867)

Closes #859

* fix(planner): wire Route toggle into mobile day sidebar (#850) (#868)

The per-booking Route icon was missing on mobile because the mobile
DayPlanSidebar invocation in TripPlannerPage didn't pass
visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't
activate reservation map overlays without forcing desktop mode.

Also corrects the Map-Features wiki: fixes the setting name
("Booking route labels" not "Show connection labels"), documents the
route_calculation requirement for travel-time pills, and explains that
overlays are off by default and must be toggled per reservation.

* chore: bump version to 3.0.8 [skip ci]

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
Co-authored-by: Xre0uS <36565320+Xre0uS@users.noreply.github.com>
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2026-04-23 19:57:50 +02:00
sss3978 73fb48e3bb Update index.test.ts 2026-04-23 19:19:20 +09:00
sss3978 d30132197e Update client/src/i18n/translations/ja.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-22 23:00:56 +09:00
sss3978 9bf4220054 Update ja.ts 2026-04-22 21:59:16 +09:00
sss3978 0d0ab5080c Update supportedLanguages.ts 2026-04-22 21:56:52 +09:00
sss3978 1084d40685 Update TranslationContext.tsx 2026-04-22 21:55:47 +09:00
sss3978 75ef928264 Create ja.ts 2026-04-22 21:54:10 +09:00
74 changed files with 2685 additions and 1260 deletions
+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.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a> <a href="https://demo-nomad.pakulat.org"><img alt="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. Email: **report@liketrek.com** 2. Emails: **mauriceboe@icloud.com**, **trek-security@jubnl.ch**
3. Include a description of the vulnerability and steps to reproduce 3. Include a description of the vulnerability and steps to reproduce
You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible. You will receive a response within 48 hours. Once confirmed, a fix will be released as soon as possible.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2 apiVersion: v2
name: trek name: trek
version: 3.0.22 version: 3.0.18
description: Minimal Helm chart for TREK app description: Minimal Helm chart for TREK app
appVersion: "3.0.22" appVersion: "3.0.18"
+2 -9
View File
@@ -1,17 +1,16 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.22", "version": "3.0.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "3.0.22", "version": "3.0.18",
"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",
@@ -5828,12 +5827,6 @@
"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",
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "3.0.22", "version": "3.0.18",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -18,7 +18,6 @@
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"dexie": "^4.4.2", "dexie": "^4.4.2",
"heic-to": "^1.4.2",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0", "mapbox-gl": "^3.22.0",
+3 -15
View File
@@ -209,7 +209,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[]; allows_client_credentials?: boolean }) => create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
apiClient.post('/oauth/clients', data).then(r => r.data), apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data), rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data), delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
@@ -407,20 +407,8 @@ 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, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
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),
@@ -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-[9999] bg-white dark:bg-zinc-950 flex flex-col overflow-hidden" style={{ height: '100dvh' }}> <div className="fixed inset-0 z-50 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,7 +132,6 @@ export function MapViewGL({
places = [], places = [],
dayPlaces = [], dayPlaces = [],
route = null, route = null,
routeSegments = [],
selectedPlaceId = null, selectedPlaceId = null,
onMarkerClick, onMarkerClick,
onMapClick, onMapClick,
@@ -163,7 +162,6 @@ 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)
@@ -444,35 +442,6 @@ 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
+4 -6
View File
@@ -8,15 +8,13 @@ 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 styles that benefit from elevation // Terrain is only genuinely useful for the satellite imagery styles — on
// data. On flat vector styles (streets/light/dark) it nudges route lines // clean flat styles like streets/light/dark it nudges route lines onto
// onto the DEM while HTML markers stay at Z=0, causing a visible drift // the DEM while our HTML markers stay at Z=0, which causes the visible
// when the map is pitched. Satellite and Outdoors are the intended styles // offset when the map is pitched. Restrict terrain to satellite.
// 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
+1 -2
View File
@@ -5,7 +5,6 @@ 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 ''
@@ -217,7 +216,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 = splitReservationDateTime(displayTime).time ?? '' const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : ''
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};">
@@ -13,7 +13,6 @@ 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,
@@ -58,10 +57,9 @@ 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, mobile = false }: DayDetailPanelProps) { export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: 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)
@@ -175,7 +173,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" } const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return ( return (
<div className="fixed z-50" 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 className="fixed z-50" style={{ bottom: 'calc(var(--bottom-nav-h) + 20px)', left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{ <div style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)', backdropFilter: 'blur(40px) saturate(180%)',
@@ -290,11 +288,7 @@ 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 => { const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
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 }}>
@@ -311,17 +305,12 @@ 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') && (
const { time: startTime } = splitReservationDateTime(r.reservation_time) <span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
const { time: endTime } = splitReservationDateTime(r.reservation_end_time) {new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })}
if (!startTime && !endTime) return null {r.reservation_end_time && ` ${fmtTime(r.reservation_end_time)}`}
return ( </span>
<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, splitReservationDateTime } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip' import Tooltip from '../shared/Tooltip'
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,17 +1487,15 @@ 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') && (
const { time: st } = splitReservationDateTime(res.reservation_time) <span style={{ fontWeight: 400 }}>
const { time: et } = splitReservationDateTime(res.reservation_end_time) {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
if (!st && !et) return null {res.reservation_end_time && ` ${(() => {
return ( const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time)
<span style={{ fontWeight: 400 }}> return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
{st ? formatTime(st, locale, timeFormat) : ''} })()}`}
{et ? ` ${formatTime(et, locale, timeFormat)}` : ''} </span>
</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
@@ -1724,20 +1722,18 @@ 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') && (
const { time: dispTime } = splitReservationDateTime(displayTime) <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
const { time: endTime } = splitReservationDateTime(res.reservation_end_time) <Clock size={9} strokeWidth={2} />
if (!dispTime && !endTime) return null {new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
return ( {spanPhase === 'single' && 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 }}> const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time)
<Clock size={9} strokeWidth={2} /> return ` ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`
{dispTime ? formatTime(dispTime, locale, timeFormat) : ''} })()}
{spanPhase === 'single' && endTime ? ` ${formatTime(endTime, locale, timeFormat)}` : ''} {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`}
{meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`}
{meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} </span>
</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' }}>
@@ -1786,17 +1782,8 @@ 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 { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
if (placeId) { if (fromReservationId && fromDayId !== day.id) {
// 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
@@ -2107,19 +2094,13 @@ 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')
const { date, time } = splitReservationDateTime(res.reservation_time) ? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
const { time: endTime } = splitReservationDateTime(res.reservation_end_time) : res.reservation_time
const dateStr = date ? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
: '' : ''
const timeStr = time ? formatTime(time, locale, timeFormat) : '' }
const endStr = endTime ? formatTime(endTime, 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 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,7 +10,6 @@ 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()
@@ -170,10 +169,7 @@ 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 const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
? ((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
@@ -348,7 +344,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, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>{place.description || googleDetails?.summary || ''}</Markdown>
</div> </div>
)} )}
@@ -382,29 +378,21 @@ 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 && (
const { date, time: startTime } = splitReservationDateTime(res.reservation_time) <div>
const { time: endTime } = splitReservationDateTime(res.reservation_end_time) <div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
return ( <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>
<> </div>
{date && ( )}
<div> {res.reservation_time?.includes('T') && (
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div> <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: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
</div> <div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
)} {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
{(startTime || endTime) && ( {res.reservation_end_time && ` ${res.reservation_end_time}`}
<div> </div>
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div> </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,51 +389,4 @@ 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,7 +15,6 @@ 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
@@ -100,13 +99,17 @@ 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 startDt = splitReservationDateTime(r.reservation_time) const fmtDate = (str) => {
const endDt = splitReservationDateTime(r.reservation_end_time) const dateOnly = str.includes('T') ? str.split('T')[0] : str
const fmtDate = (date: string) => return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' })
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 = !!startDt.date const hasDate = !!r.reservation_time
const hasTime = !!(startDt.time || endDt.time) const hasTime = r.reservation_time?.includes('T')
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
@@ -230,25 +233,31 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
</div> </div>
)} )}
{/* Date / Time row */} {/* Date / Time row */}
{(hasDate || hasTime) && ( {hasDate && (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasDate && hasTime ? '1fr 1fr' : '1fr' }}> <div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
{hasDate && ( <div>
<div> <div style={fieldLabelStyle}>{t('reservations.date')}</div>
<div style={fieldLabelStyle}>{t('reservations.date')}</div> <div style={{ ...fieldValueStyle, textAlign: 'center' }}>
<div style={{ ...fieldValueStyle, textAlign: 'center' }}> {fmtDate(r.reservation_time)}
{fmtDate(startDt.date!)} {(() => {
{endDt.date && endDt.date !== startDt.date && ( const endDatePart = r.reservation_end_time
<> {fmtDate(endDt.date)}</> ? r.reservation_end_time.includes('T')
)} ? r.reservation_end_time.split('T')[0]
</div> : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time)
? r.reservation_end_time
: null
: null
return endDatePart && endDatePart !== r.reservation_time.split('T')[0]
})() && (
<> {fmtDate(r.reservation_end_time)}</>
)}
</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' }}>
{formatTime(startDt.time, locale, timeFormat)} {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)}` : ''}
{endDt.time ? ` ${formatTime(endDt.time, locale, timeFormat)}` : ''}
</div> </div>
</div> </div>
)} )}
@@ -307,8 +316,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: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') }) 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_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_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 (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, splitReservationDateTime } from '../../utils/formatters' import { formatDate } 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: splitReservationDateTime(reservation.reservation_time).time ?? '', departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '', arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
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}` : time return day?.date ? `${day.date}T${time}` : `T${time}`
} }
const metadata: Record<string, string> = {} const metadata: Record<string, string> = {}
@@ -69,7 +69,6 @@ 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
} }
@@ -118,7 +117,6 @@ 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')
@@ -216,23 +214,16 @@ export default function IntegrationsTab(): React.ReactElement {
}, [mcpEnabled]) }, [mcpEnabled])
const handleCreateOAuthClient = async () => { const handleCreateOAuthClient = async () => {
if (!oauthNewName.trim()) return if (!oauthNewName.trim() || !oauthNewUris.trim()) return
if (!oauthIsMachine && !oauthNewUris.trim()) return
setOauthCreating(true) setOauthCreating(true)
try { try {
const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean)
const d = await oauthApi.clients.create({ const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes })
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 {
@@ -351,7 +342,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([]); setOauthIsMachine(false) }} <button onClick={() => { setOauthCreateOpen(true); setOauthCreatedClient(null); setOauthNewName(''); setOauthNewUris(''); setOauthNewScopes([]) }}
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>
@@ -369,15 +360,7 @@ 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">
<div className="flex items-center gap-2"> <p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{client.name}</p>
<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>
@@ -633,26 +616,15 @@ export default function IntegrationsTab(): React.ReactElement {
autoFocus /> autoFocus />
</div> </div>
<label className="flex items-start gap-2.5 cursor-pointer"> <div>
<input type="checkbox" checked={oauthIsMachine} onChange={e => setOauthIsMachine(e.target.checked)} <label className="block text-sm font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.redirectUris')}</label>
className="mt-0.5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" /> <textarea value={oauthNewUris} onChange={e => setOauthNewUris(e.target.value)}
<div> placeholder={t('settings.oauth.modal.redirectUrisPlaceholder')}
<span className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('settings.oauth.modal.machineClient')}</span> rows={3}
<p className="text-xs mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.machineClientHint')}</p> 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"
</div> style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} />
</label> <p className="mt-1 text-xs" style={{ color: 'var(--text-tertiary)' }}>{t('settings.oauth.modal.redirectUrisHint')}</p>
</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>
@@ -666,7 +638,7 @@ export default function IntegrationsTab(): React.ReactElement {
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button onClick={handleCreateOAuthClient} <button onClick={handleCreateOAuthClient}
disabled={!oauthNewName.trim() || (!oauthIsMachine && !oauthNewUris.trim()) || oauthCreating} disabled={!oauthNewName.trim() || !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>
@@ -709,12 +681,6 @@ 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">
+1 -13
View File
@@ -18,7 +18,6 @@ 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)
@@ -87,18 +86,7 @@ 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={() => { onError={() => setPhotoSrc(null)}
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>
) )
+3 -2
View File
@@ -15,6 +15,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 +24,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, 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 +39,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', '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 -1
View File
@@ -12,8 +12,9 @@ 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: '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,10 +330,6 @@ 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': 'الإبلاغ عن خطأ',
@@ -1678,7 +1674,6 @@ 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': 'نطاق التاريخ',
@@ -1710,11 +1705,8 @@ 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,10 +402,6 @@ 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
@@ -2081,11 +2077,8 @@ 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...',
@@ -2176,7 +2169,6 @@ 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,10 +281,6 @@ 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',
@@ -2086,11 +2082,8 @@ 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...',
@@ -2181,7 +2174,6 @@ 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,10 +330,6 @@ 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',
@@ -2089,11 +2085,8 @@ 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...',
@@ -2188,7 +2181,6 @@ 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,10 +403,6 @@ 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',
@@ -2115,11 +2111,8 @@ 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...',
@@ -2226,7 +2219,6 @@ 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,10 +326,6 @@ 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',
@@ -2088,11 +2084,8 @@ 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...',
@@ -2183,7 +2176,6 @@ 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,10 +325,6 @@ 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',
@@ -2082,11 +2078,8 @@ 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...',
@@ -2177,7 +2170,6 @@ 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,10 +280,6 @@ 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',
@@ -2083,11 +2079,8 @@ 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...',
@@ -2178,7 +2171,6 @@ 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,10 +387,6 @@ 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',
@@ -2098,11 +2094,8 @@ 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...',
@@ -2205,7 +2198,6 @@ 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,10 +280,6 @@ 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',
@@ -2083,11 +2079,8 @@ 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...',
@@ -2178,7 +2171,6 @@ 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
-8
View File
@@ -325,10 +325,6 @@ 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',
@@ -2082,11 +2078,8 @@ 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...',
@@ -2177,7 +2170,6 @@ 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,10 +295,6 @@ 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',
@@ -2075,11 +2071,8 @@ 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ę...',
@@ -2170,7 +2163,6 @@ 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,10 +325,6 @@ 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': 'Сообщить об ошибке',
@@ -2082,11 +2078,8 @@ 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': 'Напишите свою историю...',
@@ -2177,7 +2170,6 @@ 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': 'Это путешествие не существует или ссылка устарела.',
-8
View File
@@ -325,10 +325,6 @@ 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': '报告错误',
@@ -2082,11 +2078,8 @@ 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': '写下你的故事...',
@@ -2177,7 +2170,6 @@ 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,10 +384,6 @@ 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': '回報錯誤',
@@ -2040,11 +2036,8 @@ 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': '寫下你的故事...',
@@ -2135,7 +2128,6 @@ 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': '此旅程不存在或連結已過期。',
+22 -45
View File
@@ -1,7 +1,5 @@
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'
@@ -31,7 +29,6 @@ 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%)',
@@ -749,8 +746,8 @@ export default function JourneyDetailPage() {
} }
return entryId return entryId
}} }}
onUploadPhotos={async (entryId, files, cbs) => { onUploadPhotos={async (entryId, formData) => {
return await uploadPhotos(entryId, files, cbs) return await uploadPhotos(entryId, formData)
}} }}
onDone={() => { onDone={() => {
setEditingEntry(null) setEditingEntry(null)
@@ -988,8 +985,7 @@ 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 [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null) const [galleryUploading, setGalleryUploading] = useState(false)
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
@@ -1029,22 +1025,17 @@ 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
setGalleryProgress({ done: 0, total: files.length }) setGalleryUploading(true)
try { try {
const normalized = await normalizeImageFiles(files) const formData = new FormData()
const { failed } = await useJourneyStore.getState().uploadGalleryPhotos(journeyId, normalized, { for (const f of files) formData.append('photos', f)
onProgress: p => setGalleryProgress({ done: p.done, total: p.total }), await journeyApi.uploadGalleryPhotos(journeyId, formData)
}) 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 (err) { } catch {
toast.error(getApiErrorMessage(err, t('journey.photosUploadFailed'))) toast.error(t('journey.settings.coverFailed'))
} finally { } finally {
setGalleryProgress(null) setGalleryUploading(false)
} }
e.target.value = '' e.target.value = ''
} }
@@ -1089,7 +1080,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" /> {galleryProgress ? t('journey.editor.uploadingProgress', { done: String(galleryProgress.done), total: String(galleryProgress.total) }) : 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" /> {t('journey.editor.uploading')}</>
) : ( ) : (
<><Plus size={12} /> {t('common.upload')}</> <><Plus size={12} /> {t('common.upload')}</>
)} )}
@@ -1778,7 +1769,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-[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="fixed inset-0 z-[200] flex items-end md:items-center justify-center md:p-5 overscroll-none" style={{ background: 'rgba(9,9,11,0.75)' }} onClick={onClose} onTouchMove={e => { if (e.target === e.currentTarget) e.preventDefault() }}>
<div className="bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[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 */}
@@ -2178,11 +2169,10 @@ 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, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>> onUploadPhotos: (entryId: number, formData: FormData) => Promise<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 || '')
@@ -2201,7 +2191,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 [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(null) const [uploading, setUploading] = useState(false)
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[]>([])
@@ -2254,21 +2244,9 @@ 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 filesToUpload = pendingFiles const formData = new FormData()
setUploadProgress({ done: 0, total: filesToUpload.length }) for (const f of pendingFiles) formData.append('photos', f)
try { await onUploadPhotos(entryId, formData)
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) {
@@ -2287,8 +2265,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
if (!files?.length) return if (!files?.length) return
// Queue files locally until Save so cancel/close actually discards. This // Queue files locally until Save so cancel/close actually discards. This
// keeps photo behavior consistent with text fields — no silent persistence. // keeps photo behavior consistent with text fields — no silent persistence.
const normalized = await normalizeImageFiles(files) setPendingFiles(prev => [...prev, ...Array.from(files)])
setPendingFiles(prev => [...prev, ...normalized])
} }
return ( return (
@@ -2323,11 +2300,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={saving} disabled={uploading}
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"
> >
{uploadProgress ? ( {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) })}</> <><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')}</>
) : ( ) : (
<><Plus size={13} /> {t('journey.editor.uploadPhotos')}</> <><Plus size={13} /> {t('journey.editor.uploadPhotos')}</>
)} )}
+3 -5
View File
@@ -12,7 +12,6 @@ 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 }
@@ -220,7 +219,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 = splitReservationDateTime(r.reservation_time).time ?? '' const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
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(' · ')
@@ -277,9 +276,8 @@ 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 { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time) const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
const time = rTime ?? '' 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 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 }}>
+1 -2
View File
@@ -1003,7 +1003,6 @@ 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}
/> />
) )
})()} })()}
@@ -1117,7 +1116,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) }} 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); 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 }} />
: <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>
+5 -54
View File
@@ -1,7 +1,6 @@
// 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';
@@ -283,64 +282,16 @@ 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 });
// MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to
// emit upload progress events, which hangs in jsdom+Node. Spy on the API
// 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, [file]);
expect(result.succeeded).toHaveLength(1);
expect(result.succeeded[0].id).toBe(91);
expect(result.failed).toHaveLength(0);
const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100);
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( server.use(
http.post('/api/journeys/entries/100/photos', () => http.post('/api/journeys/entries/100/photos', () =>
HttpResponse.error() HttpResponse.json({ photos: [newPhoto] })
) )
); );
const file = new File(['x'], 'fail.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(0); expect(result[0].id).toBe(91);
expect(result.failed).toHaveLength(1);
expect(result.failed[0]).toBe(file);
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(0); expect(storedEntry?.photos).toHaveLength(2);
});
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 ──────────────────────────────────────────────────────────
+26 -44
View File
@@ -1,6 +1,5 @@
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
@@ -122,8 +121,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, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>> uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
uploadGalleryPhotos: (journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<GalleryPhoto>> uploadGalleryPhotos: (journeyId: number, formData: FormData) => Promise<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>
@@ -238,49 +237,32 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
} }
}, },
uploadPhotos: async (entryId, files, cbs) => { uploadPhotos: async (entryId, formData) => {
return uploadFilesResilient<JourneyPhoto>( const data = await journeyApi.uploadPhotos(entryId, formData)
files, const photos = data.photos || []
async (file, opts) => { set(s => {
const fd = new FormData() if (!s.current) return s
fd.append('photos', file) return {
const data = await journeyApi.uploadPhotos(entryId, fd, opts) current: {
const photos: JourneyPhoto[] = data.photos || [] ...s.current,
const gallery: GalleryPhoto[] = data.gallery || [] entries: s.current.entries.map(e =>
set(s => { e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e
if (!s.current) return s ),
return { gallery: [...(s.current.gallery || []), ...(data.gallery || [])],
current: { },
...s.current, }
entries: s.current.entries.map(e => })
e.id === entryId ? { ...e, photos: [...(e.photos || []), ...photos] } : e return photos
),
gallery: [...(s.current.gallery || []), ...gallery],
},
}
})
return photos
},
{ onProgress: cbs?.onProgress },
)
}, },
uploadGalleryPhotos: async (journeyId, files, cbs) => { uploadGalleryPhotos: async (journeyId, formData) => {
return uploadFilesResilient<GalleryPhoto>( const data = await journeyApi.uploadGalleryPhotos(journeyId, formData)
files, const photos: GalleryPhoto[] = data.photos || []
async (file, opts) => { set(s => {
const fd = new FormData() if (!s.current || s.current.id !== journeyId) return s
fd.append('photos', file) return { current: { ...s.current, gallery: [...(s.current.gallery || []), ...photos] } }
const data = await journeyApi.uploadGalleryPhotos(journeyId, fd, opts) })
const photos: GalleryPhoto[] = data.photos || [] return 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
@@ -1,17 +0,0 @@
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))
}
+1 -17
View File
@@ -57,27 +57,11 @@ describe('getTransportForDay', () => {
{ id: 3, day_number: 3 }, { id: 3, day_number: 3 },
] ]
it('excludes hotel (rendered via accommodation path)', () => { it('excludes non-transport types', () => {
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 (r.type === 'hotel') return false if (!TRANSPORT_TYPES.has(r.type)) 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
@@ -1,50 +0,0 @@
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,18 +65,6 @@ 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
@@ -1,106 +0,0 @@
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 }
}
+1 -1
View File
@@ -91,7 +91,7 @@ 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(16)
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: 'ar', label: 'العربية' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ar', label: 'العربية' }))
}) })
+1 -1
View File
@@ -90,7 +90,7 @@ export default defineConfig({
], ],
build: { build: {
sourcemap: false, sourcemap: false,
modulePreload: { polyfill: true }, modulePreload: { polyfill: false },
}, },
server: { server: {
port: 5173, port: 5173,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.22", "version": "3.0.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "3.0.22", "version": "3.0.18",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "3.0.22", "version": "3.0.18",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
+2 -7
View File
@@ -5,7 +5,6 @@ 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';
@@ -123,7 +122,7 @@ export function createApp(): express.Application {
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"], scriptSrc: ["'self'", "'wasm-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: [
@@ -397,7 +396,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', 'client_credentials'], grant_types_supported: ['authorization_code', 'refresh_token'],
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,
@@ -508,10 +507,6 @@ 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,42 +2229,6 @@ 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) {
+1 -2
View File
@@ -147,8 +147,7 @@ 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);
const base = getMcpSafeUrl().replace(/\/+$/, ''); res.redirect(302, `/oauth/consent?${qs.toString()}`);
res.redirect(302, `${base}/oauth/consent?${qs.toString()}`);
}, },
// Not called because skipLocalPkceValidation = true. // Not called because skipLocalPkceValidation = true.
+3 -5
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. Set price + currency to record the accommodation cost so it shows on the item.', 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.',
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,19 +136,17 @@ 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, price, currency }) => { 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 }) => {
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, price, currency }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
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 };
}); });
+6 -10
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. Set price + currency to record the cost so it shows on the item.', 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.',
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,15 +37,13 @@ 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, price, currency }) => { async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
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, price, currency }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
safeBroadcast(tripId, 'place:created', { place }); safeBroadcast(tripId, 'place:created', { place });
return ok({ place }); return ok({ place });
} }
@@ -54,7 +52,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. Set price + currency to record the cost so it shows on the item.', 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.',
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'),
@@ -70,18 +68,16 @@ 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, price, currency }) => { async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => {
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, price, currency }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null); const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment }; return { place, assignment };
}); });
+2 -18
View File
@@ -6,7 +6,6 @@ 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 {
@@ -23,7 +22,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. Set price to record the cost; it will appear on the booking and in the Budget tab.', 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.',
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),
@@ -39,12 +38,10 @@ 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, price, budget_category }) => { 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 }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -64,28 +61,15 @@ 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 });
} }
+3 -19
View File
@@ -5,7 +5,6 @@ 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,
@@ -33,7 +32,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. Set price to record the cost; it will appear on the booking and in the Budget tab.', 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.',
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']),
@@ -48,12 +47,10 @@ 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, price, budget_category }) => { async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -62,9 +59,6 @@ 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,
@@ -76,20 +70,10 @@ 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: Object.keys(meta).length > 0 ? meta : undefined, metadata,
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 });
} }
+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'), async (req: Request, res: Response) => { router.post('/entries/:entryId/photos', authenticate, upload.array('photos', 10), 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'), async (req: Request, res: Response) => { router.post('/:id/gallery/photos', authenticate, upload.array('photos', 20), 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' });
+3 -48
View File
@@ -10,7 +10,6 @@ import {
consumeAuthCode, consumeAuthCode,
saveConsent, saveConsent,
issueTokens, issueTokens,
issueClientCredentialsToken,
refreshTokens, refreshTokens,
revokeToken, revokeToken,
verifyPKCE, verifyPKCE,
@@ -25,7 +24,6 @@ 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)
@@ -153,48 +151,6 @@ 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}` });
}); });
@@ -371,14 +327,13 @@ 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, allows_client_credentials } = req.body as { const { name, redirect_uris, allowed_scopes } = 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), { allowsClientCredentials: allows_client_credentials }); const result = createOAuthClient(user.id, name, redirect_uris, allowed_scopes, getClientIp(req));
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);
}); });
+4 -2
View File
@@ -13,7 +13,7 @@ import {
updateReservation, updateReservation,
deleteReservation, deleteReservation,
} from '../services/reservationService'; } from '../services/reservationService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService'; import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
const router = express.Router({ mergeParams: true }); const router = express.Router({ mergeParams: true });
@@ -55,11 +55,13 @@ 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 = linkBudgetItemToReservation(tripId, reservation.id, { const budgetItem = createBudgetItem(tripId, {
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);
+4 -20
View File
@@ -100,12 +100,6 @@ 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> = {
@@ -150,9 +144,6 @@ 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> = {
@@ -176,7 +167,6 @@ 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 ───────────────────────────────────────────────────────
@@ -376,17 +366,11 @@ 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);
// The last part is the country; the city is usually right before it, but a let raw = parts.length >= 2 ? parts[parts.length - 2] : parts[0];
// full formatted address can have a postal code sitting between them if (raw) {
// (e.g. "Bucharest, 010071, Romania"). Walk back from the country and take const city = raw.replace(/[\d\-\u2212\u3012]+/g, '').trim().toLowerCase();
// the first part that still has letters once digits/postal noise is stripped. if (city) citySet.add(city);
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,17 +96,6 @@ 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,
+1 -6
View File
@@ -1,7 +1,6 @@
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 ───────────────────────────────────────────────────
@@ -13,11 +12,7 @@ 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}`);
const referer = process.env.APP_URL ? getAppUrl() : undefined; return fetch(endpoint, init);
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, rawBody = false): string { export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string): 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 = rawBody ? body : escapeHtml(body); const safeBody = 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, undefined, true); return buildEmailHtml(subject, block, lang);
} }
/** /**
+8 -50
View File
@@ -60,7 +60,6 @@ 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 {
@@ -107,12 +106,11 @@ 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, allows_client_credentials 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 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),
})); }));
@@ -134,12 +132,11 @@ export function createOAuthClient(
redirectUris: string[], redirectUris: string[],
allowedScopes: string[], allowedScopes: string[],
ip?: string | null, ip?: string | null,
options?: { isPublic?: boolean; createdVia?: string; allowsClientCredentials?: boolean }, options?: { isPublic?: boolean; createdVia?: string },
): { 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 };
const isMachineClient = Boolean(options?.allowsClientCredentials); if (!redirectUris || redirectUris.length === 0) return { error: 'At least one redirect URI is required', status: 400 };
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) {
@@ -167,8 +164,7 @@ export function createOAuthClient(
if (count >= 500) return { error: 'server_error', status: 503 }; if (count >= 500) return { error: 'server_error', status: 503 };
} }
// Machine clients (client_credentials) must always be confidential — ignore isPublic for them. const isPublic = options?.isPublic ?? false;
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();
@@ -177,14 +173,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, allows_client_credentials) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' 'INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, is_public, created_via) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia, isMachineClient ? 1 : 0); ).run(id, userId, name.trim(), clientId, secretHash, JSON.stringify(redirectUris), JSON.stringify(allowedScopes), isPublic ? 1 : 0, createdVia);
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, allows_client_credentials FROM oauth_clients WHERE id = ?' 'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via 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, allows_client_credentials: isMachineClient }, ip }); writeAudit({ userId, action: 'oauth.client.create', details: { client_id: clientId, name: name.trim(), is_public: isPublic }, ip });
return { return {
client: { client: {
@@ -196,7 +192,6 @@ 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 } : {}),
@@ -335,43 +330,6 @@ 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)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
-5
View File
@@ -506,11 +506,6 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
// Reservations as events // Reservations as events
for (const r of reservations) { for (const r of reservations) {
if (!r.reservation_time) continue; if (!r.reservation_time) continue;
// Skip time-only values (no calendar date — occurs on relative "Day N" trips)
const hasDate = r.reservation_time.includes('T')
? /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time.split('T')[0])
: /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time);
if (!hasDate) continue;
const hasTime = r.reservation_time.includes('T'); const hasTime = r.reservation_time.includes('T');
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {}; const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
+1 -138
View File
@@ -63,7 +63,7 @@ import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories'; import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth'; import { authCookie } from '../helpers/auth';
import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService'; import { createOAuthClient, createAuthCode } from '../../src/services/oauthService';
const app: Application = createApp(); const app: Application = createApp();
@@ -1285,141 +1285,4 @@ describe('C3 — Refresh token replay detection', () => {
expect(t4.status).toBe(400); expect(t4.status).toBe(400);
expect(t4.body.error).toBe('invalid_grant'); expect(t4.body.error).toBe('invalid_grant');
}); });
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/token — client_credentials grant
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /oauth/token — client_credentials grant', () => {
it('OAUTH-CC-001 — happy path: issues access token with no refresh_token', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
});
expect(res.status).toBe(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
expect(typeof res.body.expires_in).toBe('number');
expect(res.body.scope).toBe('trips:read');
expect(res.body.refresh_token).toBeUndefined();
});
it('OAUTH-CC-002 — issued token resolves to the client owner user', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
});
expect(res.status).toBe(200);
const info = getUserByAccessToken(res.body.access_token);
expect(info).not.toBeNull();
expect(info!.user.id).toBe(user.id);
expect(info!.scopes).toEqual(['trips:read']);
});
it('OAUTH-CC-003 — wrong client_secret returns 401 invalid_client', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: 'trekcs_wrong',
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-CC-004 — missing client_secret returns 401 invalid_client', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-CC-005 — non-machine client returns 400 unauthorized_client', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'BrowserApp', ['https://app.example.com/cb'], ['trips:read']);
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('unauthorized_client');
});
it('OAUTH-CC-006 — scope narrowing: requested subset is honoured', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read', 'places:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
scope: 'trips:read',
});
expect(res.status).toBe(200);
expect(res.body.scope).toBe('trips:read');
});
it('OAUTH-CC-007 — scope outside allowed_scopes returns 400 invalid_scope', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
const res = await request(app)
.post('/oauth/token')
.send({
grant_type: 'client_credentials',
client_id: r.client!.client_id,
client_secret: r.client!.client_secret,
scope: 'places:write',
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_scope');
});
it('OAUTH-CC-008 — createOAuthClient with allowsClientCredentials succeeds without redirect URIs', () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'Machine', [], ['trips:read'], null, { allowsClientCredentials: true });
expect(r.error).toBeUndefined();
expect(r.client).toBeDefined();
expect(r.client!.allows_client_credentials).toBe(true);
expect((r.client!.redirect_uris as string[]).length).toBe(0);
expect(r.client!.client_secret).toBeDefined();
});
}); });
-1
View File
@@ -37,7 +37,6 @@ Each entry corresponds to a day in your journey. The entry editor provides:
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold. - **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Cold.
- **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views. - **Photos** — attach photos to the entry. The first photo becomes the card thumbnail in list views.
> **Note on HEIC files:** HEIC is an Apple-only format that many browsers and platforms do not recognise as an image. To ensure broad compatibility, HEIC/HEIF files are automatically converted to JPEG before upload. This conversion may result in the loss of embedded metadata (EXIF data such as GPS coordinates, camera information, etc.).
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry. - **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
- **Tags** — free-form labels (e.g. "hidden gem", "best meal"). - **Tags** — free-form labels (e.g. "hidden gem", "best meal").
- **Location** — pin the entry to a map location. - **Location** — pin the entry to a map location.
-10
View File
@@ -18,16 +18,6 @@ Once connected, an AI assistant can work with your TREK data in a single convers
Changes made through MCP are broadcast to all connected clients in real-time — exactly like changes made in the web UI. Changes made through MCP are broadcast to all connected clients in real-time — exactly like changes made in the web UI.
## Authentication options
| Use case | Method |
|---|---|
| Interactive client (Claude.ai, Cursor, VS Code…) | OAuth 2.1 with browser consent — TREK issues tokens after you approve scopes in a consent screen |
| AI agent or script running unattended | Machine client (client_credentials) — token obtained directly via `client_id` + `client_secret`, no browser ever opened |
| Legacy setups | Static API token — deprecated, full access, no scopes |
See [MCP-Setup](MCP-Setup) for step-by-step instructions for each method.
## Requirements ## Requirements
- **MCP addon enabled** — an administrator must enable the MCP addon (`mcp`) from the Admin Panel before the `/mcp` endpoint becomes available and the MCP section appears in user settings. - **MCP addon enabled** — an administrator must enable the MCP addon (`mcp`) from the Admin Panel before the `/mcp` endpoint becomes available and the MCP section appears in user settings.
+26 -39
View File
@@ -1,6 +1,6 @@
# MCP Setup # MCP Setup
This page explains how to connect an AI assistant to your TREK instance. TREK supports three authentication methods: OAuth 2.1 with browser consent (recommended for interactive clients), machine clients with no browser login (recommended for AI agents and scripts), and static API tokens (deprecated). This page explains how to connect an AI assistant to your TREK instance. TREK supports two authentication methods: OAuth 2.1 (recommended) and static API tokens (deprecated).
<!-- TODO: screenshot: OAuth client registration form --> <!-- TODO: screenshot: OAuth client registration form -->
@@ -23,12 +23,25 @@ Claude.ai (web) supports native MCP connections — no JSON config file required
### Claude Desktop ### Claude Desktop
Claude Desktop supports native MCP connections — no JSON config file required: Claude Desktop connects via `mcp-remote`. After creating an OAuth client using the **Claude Desktop** preset (redirect URI: `http://localhost`), add the following to your Claude Desktop config:
1. In TREK, go to **Settings → Integrations → MCP → OAuth Clients** and click **Create**. ```json
2. Select the **Claude Desktop** preset. This fills in the redirect URI and a default scope set. {
3. Give the client a name, adjust scopes if needed, and save. Copy the client ID and client secret — the secret is shown only once. "mcpServers": {
4. In Claude Desktop, open Settings → MCP and add a new server using your TREK URL (`https://<your-trek-instance>/mcp`). Claude Desktop will open your browser to complete the OAuth consent flow. "trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://<your-trek-instance>/mcp",
"--static-oauth-client-info",
"{\"client_id\": \"<your_client_id>\", \"client_secret\": \"<your_client_secret>\"}"
]
}
}
}
```
When the client starts it opens your browser to the TREK consent screen to complete the OAuth flow.
### Cursor, VS Code, Windsurf, and Zed ### Cursor, VS Code, Windsurf, and Zed
@@ -86,34 +99,9 @@ Create a client in TREK using the appropriate preset (Cursor, VS Code, Windsurf,
Each user can have up to **10 OAuth clients**. Each user can have up to **10 OAuth clients**.
## Option B: Machine client — no browser login (for AI agents and scripts) ## Option B: Static API token (deprecated)
Use this when your AI agent or automation script needs to authenticate silently without any browser interaction. Instead of going through an OAuth consent flow, the client exchanges a `client_id` and `client_secret` directly for an access token ([RFC 6749 §4.4 — Client Credentials grant](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)). > **Deprecated:** Static tokens will stop working in a future version of TREK. Migrate to OAuth 2.1.
**Why this exists:** browser-based OAuth flows break when an AI agent runs unattended. The agent may fire multiple concurrent token refreshes, causing replay detection to invalidate the session and open browser windows. Machine clients sidestep this entirely — there is no refresh token and no rotation race.
**How it works:** the token acts as its owner (the user who created the client), scoped to the permissions chosen at creation. All TREK permission checks still apply — the AI agent can only access what you can access, narrowed further to the selected scopes.
### Create a machine client
1. Go to **Settings → Integrations → MCP → OAuth Clients** and click **New Client**.
2. Tick **Machine client (no browser login)**. The redirect URI field disappears — machine clients don't need one.
3. Give it a name, select scopes, and click **Register Client**.
4. Copy the `client_id` and `client_secret` shown — the secret is displayed only once.
### How token management works
Your AI client uses the `client_id` and `client_secret` to request a token directly from TREK (`POST /oauth/token` with `grant_type=client_credentials`). Tokens are valid for 1 hour. When one expires, the client requests a new one silently — no browser window, no user action, no consent screen. This is handled entirely by the client.
### Who should use this
Machine clients are designed for **AI agent frameworks and custom MCP client implementations** that can call the token endpoint themselves and handle renewal programmatically. TREK advertises `client_credentials` in its OAuth discovery document (`/.well-known/oauth-authorization-server`), so any compliant client can discover and use it automatically.
> **`mcp-remote` users:** `mcp-remote` implements the browser-based `authorization_code` flow only — it does not support `client_credentials`. If you use `mcp-remote`, stick with Option A and use the preset for your client. The machine client option is not applicable.
## Option C: Static API token (deprecated)
> **Deprecated:** Static tokens will stop working in a future version of TREK. Migrate to OAuth 2.1 or machine clients.
Static tokens grant full access to all tools and resources with no scope restrictions. Sessions using a static token will receive deprecation warnings in the AI client on every tool call. Static tokens grant full access to all tools and resources with no scope restrictions. Sessions using a static token will receive deprecation warnings in the AI client on every tool call.
@@ -141,12 +129,11 @@ Each user can create up to **10 static tokens**.
## Authentication reference ## Authentication reference
| Method | Grant | Token prefix | Access level | Expiry | | Method | Token prefix | Access level | Expiry |
|---|---|---|---|---| |---|---|---|---|
| OAuth 2.1 — browser consent | `authorization_code` | `trekoa_` | Scoped (per-consent) | 1 hour; auto-refreshed via 30-day rolling refresh token (`trekrf_`) | | OAuth 2.1 access token | `trekoa_` | Scoped (per-consent) | 1 hour; auto-refreshed via 30-day rolling refresh token (`trekrf_`) |
| Machine client — no browser | `client_credentials` | `trekoa_` | Scoped (per-client), acts as owner | 1 hour; re-request silently, no refresh token | | OAuth client secret | `trekcs_` | Used during OAuth registration | No expiry (revoke via UI) |
| OAuth client secret | — | `trekcs_` | Used to authenticate the client at the token endpoint | No expiry (revoke via UI) | | Static API token | `trek_` | Full access | No expiry — **deprecated** |
| Static API token | — | `trek_` | Full access | No expiry — **deprecated** |
## Related ## Related
+1 -2
View File
@@ -44,7 +44,6 @@ When generating the API key in Immich (**Account Settings → API Keys**), grant
| `asset.read` | Read photo metadata and search results | | `asset.read` | Read photo metadata and search results |
| `asset.view` | Load thumbnails and preview images | | `asset.view` | Load thumbnails and preview images |
| `album.read` | List owned + shared albums and their contents | | `album.read` | List owned + shared albums and their contents |
| `asset.download` | Download the assets |
| `asset.upload` | *Only if you enable "Mirror journey photos to Immich on upload"* — push TREK uploads back to your library | | `asset.upload` | *Only if you enable "Mirror journey photos to Immich on upload"* — push TREK uploads back to your library |
TREK never modifies or deletes anything in Immich, so no `update`, `delete`, or admin scopes are needed. TREK never modifies or deletes anything in Immich, so no `update`, `delete`, or admin scopes are needed.
@@ -95,4 +94,4 @@ Once a provider is connected, you can browse and attach photos to your trips. Se
## See also ## See also
- [Admin-Addons](Admin-Addons) - [Admin-Addons](Admin-Addons)
- [Internal-Network-Access](Internal-Network-Access) - [Internal-Network-Access](Internal-Network-Access)
-2
View File
@@ -21,8 +21,6 @@ Type in the search box at the top of the form. After 2 or more characters, with
When a key is present, the autocomplete uses the Google Places API, which can return ratings, opening hours, photos, and phone numbers from Google's database. When a key is present, the autocomplete uses the Google Places API, which can return ratings, opening hours, photos, and phone numbers from Google's database.
> **API key restrictions:** TREK calls the Google Places API from the server, not the browser. If you apply **HTTP referrers** restrictions to your key in Google Cloud Console, you must also set `APP_URL` in your environment — TREK sends it as the `Referer` header on every outbound Google API request. Without it, Google will reject all server-side calls with `REQUEST_DENIED`. For server-side deployments, **IP address** restrictions are simpler and require no extra configuration. See [Troubleshooting](Troubleshooting) if photos are missing after adding a key.
### Without a Google Maps API key ### Without a Google Maps API key
TREK falls back to OpenStreetMap (Nominatim) automatically — no API key needed. A notice appears above the search box explaining that OpenStreetMap is in use and that photos, ratings, and opening hours are unavailable. Results include name, address, and coordinates. TREK falls back to OpenStreetMap (Nominatim) automatically — no API key needed. A notice appears above the search box explaining that OpenStreetMap is in use and that photos, ratings, and opening hours are unavailable. Results include name, address, and coordinates.
-39
View File
@@ -223,45 +223,6 @@ If `ALLOWED_ORIGINS` is not set, TREK allows all origins (development default).
--- ---
## Place photos not loading / place thumbnail shows default map pin (Google Maps API key configured)
**Cause:** When a Google Maps API key is set, TREK fetches photo references and image bytes from the Google Places API on the server side. If the server-side call is rejected or returns no photos, the `/place-photo/:id` endpoint returns 404 and the place falls back to the default map-pin thumbnail. The most common causes are:
1. **HTTP referrer restriction on the API key.** Google Cloud Console lets you restrict a key to specific HTTP referrers. Because TREK calls Google from the server (not the browser), it sends a `Referer` header derived from `APP_URL`. If `APP_URL` is not set, the fallback is `http://localhost:<PORT>`, which will not match any domain whitelist in GCP.
2. **Wrong key restriction type.** API keys restricted by **HTTP referrers** are designed for browser-side JavaScript. For a self-hosted server application, use **IP address** restrictions instead — add the public IP of your TREK server and no `APP_URL` configuration is needed.
3. **Places API (New) not enabled.** The key must have **Places API (New)** enabled in Google Cloud Console under APIs & Services → Enabled APIs. Enabling only the legacy Places API is not sufficient.
4. **Billing not set up.** Google requires a billing account to be linked to the project even within the free tier. Without it, photo and details requests return `REQUEST_DENIED`.
**Fix for HTTP referrer restriction:**
Set `APP_URL` to the public URL of your instance and add that URL (or its domain with a wildcard, e.g. `https://trek.example.com/*`) to the allowed referrers in GCP:
```yaml
environment:
- APP_URL=https://trek.example.com
```
**Fix for wrong restriction type:**
Switch the key's "Application restrictions" from **HTTP referrers** to **IP addresses** in Google Cloud Console, and add your server's public IP. No `APP_URL` change needed.
**Verifying the issue:**
Run the following curl command using your key to check whether Google returns photo references:
```bash
curl "https://places.googleapis.com/v1/places/<PLACE_ID>" \
-H "X-Goog-Api-Key: YOUR_API_KEY" \
-H "X-Goog-FieldMask: photos"
```
If the response is `{}` or `{"error": {...}}`, the key or its restrictions are blocking the request. If it returns a `photos` array, the key is valid and the issue is elsewhere.
---
## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts ## MCP OAuth flow does not initiate / "Connect" redirects but authentication never starts
**Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes. **Cause:** TREK builds the OAuth 2.1 redirect URI from `APP_URL`. If `APP_URL` is not set, the authorization URL is constructed from a localhost fallback that external clients (Claude.ai, Claude Desktop) cannot reach, so the OAuth handshake never completes.