mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d534f13cf | |||
| ffa10cac65 | |||
| b85f8c5bca | |||
| da39b570eb | |||
| 151950d08a | |||
| e562d7a7ec | |||
| d0383c06c3 | |||
| 5978eec270 | |||
| 242d1bf8d4 | |||
| 4a8260dfbc | |||
| 076a752ee7 | |||
| 545d62c400 | |||
| f8542b4d87 | |||
| c2fea0a26a | |||
| 25bdf56d16 | |||
| d07b508a77 | |||
| 9ddb2f4cd0 | |||
| 5691149a82 |
@@ -16,6 +16,7 @@ structured API.
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
- [Compound Tools](#compound-tools)
|
||||
- [Prompts](#prompts)
|
||||
- [Example](#example)
|
||||
|
||||
@@ -140,13 +141,17 @@ that match your granted scopes for that session.
|
||||
| `vacay:write` | Manage vacation plans | Vacation |
|
||||
| `geo:read` | Maps & geocoding | Geo |
|
||||
| `weather:read` | Weather forecasts | Weather |
|
||||
| `journey:read` | View journeys | Journey |
|
||||
| `journey:write` | Manage journeys | Journey |
|
||||
| `journey:share` | Manage journey share links | Journey |
|
||||
|
||||
**Scope rules:**
|
||||
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
|
||||
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
|
||||
- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access.
|
||||
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
|
||||
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
|
||||
- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled.
|
||||
- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled.
|
||||
|
||||
---
|
||||
|
||||
@@ -167,7 +172,7 @@ that match your granted scopes for that session.
|
||||
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
|
||||
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
|
||||
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
|
||||
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. |
|
||||
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. |
|
||||
|
||||
---
|
||||
|
||||
@@ -194,7 +199,6 @@ making changes.
|
||||
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
|
||||
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
|
||||
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
|
||||
| Files | `trek://trips/{tripId}/files` | Files attached to a trip (excludes trashed files) |
|
||||
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
|
||||
| Categories | `trek://categories` | Available place categories (for use when creating places) |
|
||||
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
|
||||
@@ -214,6 +218,10 @@ These resources are only available when the corresponding addon is enabled by an
|
||||
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
|
||||
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
|
||||
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
|
||||
| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user |
|
||||
| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips |
|
||||
| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) |
|
||||
| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey |
|
||||
|
||||
---
|
||||
|
||||
@@ -226,7 +234,23 @@ trip in a single call.
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, files, and poll/message counts. Use this as your context loader. |
|
||||
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. |
|
||||
|
||||
### Compound Tools
|
||||
|
||||
Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically.
|
||||
|
||||
> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly.
|
||||
|
||||
| Tool | Wraps | Description |
|
||||
|---|---|---|
|
||||
| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. |
|
||||
| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. |
|
||||
| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. |
|
||||
|
||||
**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required).
|
||||
|
||||
---
|
||||
|
||||
### Trips
|
||||
|
||||
@@ -247,14 +271,18 @@ trip in a single call.
|
||||
|
||||
### Places
|
||||
|
||||
> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|------------------|--------------------------------------------------------------------------------------------------|
|
||||
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
||||
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
| `list_categories`| List all available place categories with id, name, icon and color. |
|
||||
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
||||
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
|
||||
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
|
||||
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
|
||||
| `delete_place` | Remove a place from a trip. |
|
||||
| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** |
|
||||
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |
|
||||
| `list_categories` | List all available place categories with id, name, icon and color. |
|
||||
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
|
||||
|
||||
### Day Planning
|
||||
|
||||
@@ -273,24 +301,40 @@ trip in a single call.
|
||||
|
||||
### Accommodations
|
||||
|
||||
> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|------------------------|------------------------------------------------------------------------------------------|
|
||||
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
|
||||
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
|
||||
| `delete_accommodation` | Delete an accommodation record from a trip. |
|
||||
|
||||
### Transport
|
||||
|
||||
Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight.
|
||||
|
||||
| Tool | Description |
|
||||
|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. |
|
||||
| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. |
|
||||
| `delete_transport` | Delete a transport booking from a trip. |
|
||||
|
||||
### Reservations
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `reorder_reservations` | Update the display order of reservations within a day. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types.
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
|
||||
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
|
||||
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
|
||||
| `reorder_reservations` | Update the display order of reservations (and transports) within a day. |
|
||||
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
|
||||
|
||||
### Budget
|
||||
|
||||
> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools).
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------------|---------------------------------------------------------------------------------------|
|
||||
| `create_budget_item` | Add an expense with name, category, and price. |
|
||||
@@ -370,7 +414,14 @@ trip in a single call.
|
||||
| `get_weather` | Get weather forecast for a location and date. |
|
||||
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
|
||||
|
||||
### Collab Notes
|
||||
### Airports
|
||||
|
||||
| Tool | Description |
|
||||
|-------------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. |
|
||||
| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). |
|
||||
|
||||
### Collab Notes _(Collab addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|----------------------|-------------------------------------------------------------------------------------------------|
|
||||
@@ -392,14 +443,14 @@ trip in a single call.
|
||||
| `delete_collab_message`| Delete a chat message (own messages only). |
|
||||
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
|
||||
|
||||
### Bucket List
|
||||
### Bucket List _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------|
|
||||
| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. |
|
||||
| `delete_bucket_list_item` | Remove an item from your bucket list. |
|
||||
|
||||
### Atlas
|
||||
### Atlas _(Atlas addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|--------------------------|---------------------------------------------------------------------------------|
|
||||
@@ -444,6 +495,33 @@ trip in a single call.
|
||||
| `list_holiday_countries` | List countries available for public holiday calendars. |
|
||||
| `list_holidays` | List public holidays for a country and year. |
|
||||
|
||||
### Journey _(Journey addon required)_
|
||||
|
||||
| Tool | Description |
|
||||
|-----------------------------------|------------------------------------------------------------------------------------------------------------|
|
||||
| `list_journeys` | List all journeys owned or contributed to by the current user. |
|
||||
| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. |
|
||||
| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. |
|
||||
| `update_journey` | Update a journey's title, subtitle, or status. |
|
||||
| `delete_journey` | Delete a journey. |
|
||||
| `add_journey_trip` | Link an existing trip to a journey. |
|
||||
| `remove_journey_trip` | Remove a trip from a journey. |
|
||||
| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). |
|
||||
| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. |
|
||||
| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. |
|
||||
| `delete_journey_entry` | Remove an entry from a journey. |
|
||||
| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. |
|
||||
| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). |
|
||||
| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. |
|
||||
| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. |
|
||||
| `remove_journey_contributor` | Remove a contributor from a journey. |
|
||||
| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). |
|
||||
| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). |
|
||||
| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. |
|
||||
| `get_journey_share_link` | Get the current public share link for a journey. |
|
||||
| `create_journey_share_link` | Create or update the public share link for a journey. |
|
||||
| `delete_journey_share_link` | Revoke the public share link for a journey. |
|
||||
|
||||
---
|
||||
|
||||
## Prompts
|
||||
|
||||
Generated
+223
-1
@@ -13,6 +13,7 @@
|
||||
"dexie": "^4.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -2867,6 +2868,41 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-supported": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz",
|
||||
"integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz",
|
||||
"integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mswjs/interceptors": {
|
||||
"version": "0.41.3",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz",
|
||||
@@ -3842,9 +3878,17 @@
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson-vt": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
|
||||
"integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -3879,6 +3923,12 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pbf": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
||||
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -3929,6 +3979,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -4749,6 +4808,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/cheap-ruler": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz",
|
||||
"integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
@@ -5066,6 +5131,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csscolorparser": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
|
||||
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -5335,6 +5406,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -5982,6 +6059,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geojson-vt": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz",
|
||||
"integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -6054,6 +6137,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
@@ -6168,6 +6257,12 @@
|
||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/grid-index": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
|
||||
"integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -7193,6 +7288,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
@@ -7388,6 +7489,44 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.22.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.22.0.tgz",
|
||||
"integrity": "sha512-ZIpF+oAMcQoDlvABmiRkHoydyBR9zI6CyDeVRa2/iyua0/B2+rPuIzoCV/CgN14P5F0HVk53GIZw220WSqJPyA==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
"packages/pmtiles-provider",
|
||||
"test/build/vite",
|
||||
"test/build/webpack",
|
||||
"test/build/typings"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-supported": "^3.0.0",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/geojson-vt": "^3.2.5",
|
||||
"@types/pbf": "^3.0.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"cheap-ruler": "^4.0.0",
|
||||
"csscolorparser": "~1.0.3",
|
||||
"earcut": "^3.0.1",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"grid-index": "^1.1.0",
|
||||
"kdbush": "^4.0.2",
|
||||
"martinez-polygon-clipping": "^0.8.1",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.0.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"supercluster": "^8.0.1",
|
||||
"tinyqueue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -7410,6 +7549,17 @@
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/martinez-polygon-clipping": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.8.1.tgz",
|
||||
"integrity": "sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"robust-predicates": "^2.0.4",
|
||||
"splaytree": "^0.1.4",
|
||||
"tinyqueue": "3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -8411,6 +8561,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
||||
@@ -8688,6 +8844,18 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -8900,6 +9068,12 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pretty-bytes": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
|
||||
@@ -8956,6 +9130,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -9005,6 +9185,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -9457,6 +9643,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
@@ -9481,6 +9676,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
||||
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
@@ -9996,6 +10197,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/splaytree": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
|
||||
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -10347,6 +10554,15 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -10621,6 +10837,12 @@
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"dexie": "^4.4.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"marked": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -343,6 +343,7 @@ export const journeyApi = {
|
||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data),
|
||||
|
||||
// Photos
|
||||
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
|
||||
|
||||
@@ -32,8 +32,8 @@ describe('SCOPE_GROUPS', () => {
|
||||
})
|
||||
|
||||
describe('ALL_SCOPES', () => {
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(24)
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(27)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||
|
||||
@@ -38,6 +38,9 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
|
||||
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
|
||||
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
|
||||
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
|
||||
}
|
||||
|
||||
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
||||
|
||||
@@ -183,6 +183,12 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
maxZoom: 18,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
// Leaflet defaults updateWhenIdle:true on mobile (waits for pan to settle
|
||||
// before loading tiles). On the journey mobile combined view we flyTo
|
||||
// constantly when switching cards, so tiles lag visibly — force eager
|
||||
// updates and keep a larger ring of off-screen tiles ready.
|
||||
updateWhenIdle: false,
|
||||
keepBuffer: 4,
|
||||
} as any).addTo(map)
|
||||
|
||||
const items = buildMarkerItems(entries)
|
||||
@@ -244,7 +250,7 @@ const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
const pb = paddingBottom || 50
|
||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 14 })
|
||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import JourneyMap, { type JourneyMapHandle } from './JourneyMap'
|
||||
import JourneyMapGL, { type JourneyMapGLHandle } from './JourneyMapGL'
|
||||
|
||||
// Unified handle — both providers expose the same three methods.
|
||||
export type JourneyMapAutoHandle = JourneyMapHandle
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
checkins: unknown[]
|
||||
entries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
height?: number
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
}
|
||||
|
||||
const JourneyMapAuto = forwardRef<JourneyMapAutoHandle, Props>(function JourneyMapAuto(props, ref) {
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
const leafletRef = useRef<JourneyMapHandle>(null)
|
||||
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||
// supplied a token yet — otherwise the map would just show a stub.
|
||||
const useGL = provider === 'mapbox-gl' && !!token
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||
focusMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.focusMarker(id),
|
||||
invalidateSize: () => (useGL ? glRef.current : leafletRef.current)?.invalidateSize(),
|
||||
}), [useGL])
|
||||
|
||||
if (useGL) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <JourneyMapGL ref={glRef} {...(props as any)} />
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return <JourneyMap ref={leafletRef} {...(props as any)} />
|
||||
})
|
||||
|
||||
export default JourneyMapAuto
|
||||
@@ -0,0 +1,464 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
|
||||
export interface JourneyMapGLHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
focusMarker: (id: string) => void
|
||||
invalidateSize: () => void
|
||||
}
|
||||
|
||||
interface MapEntry {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
checkins: unknown[]
|
||||
entries: MapEntry[]
|
||||
trail?: { lat: number; lng: number }[]
|
||||
height?: number
|
||||
dark?: boolean
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
label: string
|
||||
locationName: string
|
||||
time: string
|
||||
}
|
||||
|
||||
const MARKER_W = 28
|
||||
const MARKER_H = 36
|
||||
|
||||
function buildItems(entries: MapEntry[]): Item[] {
|
||||
const items: Item[] = []
|
||||
for (const e of entries) {
|
||||
if (e.lat && e.lng) {
|
||||
items.push({
|
||||
id: e.id,
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
label: e.title || '',
|
||||
locationName: e.location_name || '',
|
||||
time: e.entry_date,
|
||||
})
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => a.time.localeCompare(b.time))
|
||||
return items
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function formatEntryDate(iso: string): string {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
// Inject the popup styles once per document. Two-line frosted-glass card in
|
||||
// the Apple/Google Maps idiom — title on top, location / date subtly below.
|
||||
function ensureJourneyPopupStyle() {
|
||||
if (document.getElementById('trek-journey-popup-style')) return
|
||||
const s = document.createElement('style')
|
||||
s.id = 'trek-journey-popup-style'
|
||||
s.textContent = `
|
||||
.mapboxgl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content {
|
||||
padding: 9px 14px 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
font-family: -apple-system, system-ui, sans-serif;
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content {
|
||||
background: rgba(24, 24, 27, 0.88);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: #FAFAFA;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip {
|
||||
border-top-color: rgba(255, 255, 255, 0.94);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip {
|
||||
border-top-color: rgba(24, 24, 27, 0.88);
|
||||
border-bottom-color: rgba(24, 24, 27, 0.88);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button { display: none; }
|
||||
.trek-journey-popup-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #18181B;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||
.trek-journey-popup-sub {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: #71717A;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||
.trek-journey-popup-place {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.trek-journey-popup-sep {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.55;
|
||||
font-weight: 500;
|
||||
}
|
||||
.trek-journey-popup-date { flex: 0 0 auto; }
|
||||
@keyframes trek-journey-popup-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
function markerHtml(index: number, highlighted: boolean, dark: boolean): HTMLDivElement {
|
||||
const fill = dark
|
||||
? (highlighted ? '#FAFAFA' : '#A1A1AA')
|
||||
: (highlighted ? '#18181B' : '#52525B')
|
||||
const textColor = highlighted ? (dark ? '#18181B' : '#fff') : '#fff'
|
||||
const stroke = highlighted
|
||||
? (dark ? '#fff' : '#18181B')
|
||||
: (dark ? '#3F3F46' : '#fff')
|
||||
const shadow = highlighted
|
||||
? (dark
|
||||
? 'drop-shadow(0 0 10px rgba(255,255,255,0.35)) drop-shadow(0 2px 6px rgba(0,0,0,0.4))'
|
||||
: 'drop-shadow(0 0 10px rgba(0,0,0,0.3)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))')
|
||||
: 'drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
|
||||
const scale = highlighted ? 1.2 : 1
|
||||
const label = String(index + 1)
|
||||
|
||||
// Outer wrap holds the element mapbox positions via `transform: translate(...)`.
|
||||
// Anything animated (scale, filter) has to live on an inner child — otherwise
|
||||
// the CSS transition would catch the map's per-frame translate updates and
|
||||
// the marker smears all over the viewport while scrolling / flying.
|
||||
const wrap = document.createElement('div')
|
||||
wrap.style.cssText = `width:${MARKER_W}px;height:${MARKER_H}px;cursor:pointer;`
|
||||
const inner = document.createElement('div')
|
||||
inner.className = 'trek-journey-marker-inner'
|
||||
inner.style.cssText = `width:100%;height:100%;transform:scale(${scale});transform-origin:bottom center;transition:transform 0.2s ease;filter:${shadow};`
|
||||
inner.innerHTML = `<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
||||
<circle cx="14" cy="13" r="8" fill="${fill}"/>
|
||||
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
|
||||
</svg>`
|
||||
wrap.appendChild(inner)
|
||||
return wrap
|
||||
}
|
||||
|
||||
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
|
||||
|
||||
const JourneyMapGL = forwardRef<JourneyMapGLHandle, Props>(function JourneyMapGL(
|
||||
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom },
|
||||
ref
|
||||
) {
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map())
|
||||
const itemsRef = useRef<Item[]>([])
|
||||
const highlightedRef = useRef<string | null>(null)
|
||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||
const onMarkerClickRef = useRef(onMarkerClick)
|
||||
onMarkerClickRef.current = onMarkerClick
|
||||
const darkRef = useRef(dark)
|
||||
darkRef.current = dark
|
||||
|
||||
const showPopup = useCallback((id: string) => {
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (!item || !mapRef.current) return
|
||||
ensureJourneyPopupStyle()
|
||||
// Primary line: user-given title. If none, fall back to the location
|
||||
// name so we always show *something* useful on the top line.
|
||||
const primaryRaw = item.label || item.locationName || 'Entry'
|
||||
const secondaryPlace = item.label ? item.locationName : ''
|
||||
const dateStr = formatEntryDate(item.time)
|
||||
const primary = escapeHtml(primaryRaw)
|
||||
const place = escapeHtml(secondaryPlace)
|
||||
const date = escapeHtml(dateStr)
|
||||
|
||||
const subParts: string[] = []
|
||||
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`)
|
||||
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`)
|
||||
const subline = subParts.length === 2
|
||||
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
|
||||
: subParts.join('')
|
||||
|
||||
const html = `
|
||||
<div class="trek-journey-popup-title">${primary}</div>
|
||||
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
|
||||
`
|
||||
// Marker is bottom-anchored with a visible height of 36px (1.2× on
|
||||
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
|
||||
const offset: [number, number] = [0, -46]
|
||||
if (popupRef.current) {
|
||||
popupRef.current.setLngLat([item.lng, item.lat])
|
||||
popupRef.current.setHTML(html)
|
||||
popupRef.current.setOffset(offset)
|
||||
const el = popupRef.current.getElement()
|
||||
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||||
} else {
|
||||
popupRef.current = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
closeOnMove: false,
|
||||
anchor: 'bottom',
|
||||
offset,
|
||||
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
|
||||
maxWidth: '280px',
|
||||
})
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.setHTML(html)
|
||||
.addTo(mapRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hidePopup = useCallback(() => {
|
||||
if (popupRef.current) {
|
||||
try { popupRef.current.remove() } catch { /* noop */ }
|
||||
popupRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setMarkerStyle = useCallback((id: string, highlighted: boolean) => {
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (!item || !marker) return
|
||||
const idx = itemsRef.current.indexOf(item)
|
||||
const el = marker.getElement()
|
||||
const currentInner = el.querySelector('.trek-journey-marker-inner') as HTMLDivElement | null
|
||||
if (!currentInner) return
|
||||
// Only swap the inner element's styles/HTML. Touching `el.style.cssText`
|
||||
// would wipe mapbox's positional transform and make the marker flicker.
|
||||
const next = markerHtml(idx, highlighted, !!darkRef.current)
|
||||
const nextInner = next.querySelector('.trek-journey-marker-inner') as HTMLDivElement
|
||||
currentInner.style.cssText = nextInner.style.cssText
|
||||
currentInner.innerHTML = nextInner.innerHTML
|
||||
el.style.zIndex = highlighted ? '1000' : '0'
|
||||
}, [])
|
||||
|
||||
const highlightMarker = useCallback((id: string | null) => {
|
||||
const prev = highlightedRef.current
|
||||
highlightedRef.current = id
|
||||
if (prev && prev !== id) setMarkerStyle(prev, false)
|
||||
if (id) {
|
||||
setMarkerStyle(id, true)
|
||||
showPopup(id)
|
||||
} else {
|
||||
hidePopup()
|
||||
}
|
||||
}, [setMarkerStyle, showPopup, hidePopup])
|
||||
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (!marker || !mapRef.current) return
|
||||
try {
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 600,
|
||||
})
|
||||
} catch { /* map not yet ready */ }
|
||||
}, [highlightMarker, mapbox3d])
|
||||
|
||||
const invalidateSize = useCallback(() => {
|
||||
try { mapRef.current?.resize() } catch { /* map not yet ready */ }
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker, invalidateSize }), [highlightMarker, focusMarker, invalidateSize])
|
||||
|
||||
// Build map once per style/token change. Markers and layers are rebuilt
|
||||
// inside the same effect so they stay in sync with the active style.
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const items = buildItems(entries)
|
||||
itemsRef.current = items
|
||||
|
||||
const bounds = new mapboxgl.LngLatBounds()
|
||||
items.forEach(i => bounds.extend([i.lng, i.lat]))
|
||||
stableTrail.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||
const hasPoints = items.length > 0 || stableTrail.length > 0
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: containerRef.current,
|
||||
style: mapboxStyle,
|
||||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||
zoom: hasPoints ? 2 : 1,
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(mapboxStyle)) addCustom3dBuildings(map, !!darkRef.current)
|
||||
}
|
||||
// Flatten Mapbox Standard's built-in DEM so HTML markers (at Z=0)
|
||||
// stay pinned to their coordinates at every zoom and pitch.
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
|
||||
// route trail — dashed line connecting entries in time order
|
||||
if (items.length > 1) {
|
||||
const coords = items.map(i => [i.lng, i.lat])
|
||||
if (map.getSource('journey-route')) (map.getSource('journey-route') as mapboxgl.GeoJSONSource).setData({
|
||||
type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString,
|
||||
})
|
||||
else {
|
||||
map.addSource('journey-route', {
|
||||
type: 'geojson',
|
||||
data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } as GeoJSON.LineString },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'journey-route-line',
|
||||
type: 'line',
|
||||
source: 'journey-route',
|
||||
paint: {
|
||||
'line-color': darkRef.current ? '#71717A' : '#A1A1AA',
|
||||
'line-width': 1.5,
|
||||
'line-opacity': 0.5,
|
||||
'line-dasharray': [2, 3],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// markers
|
||||
items.forEach((item, i) => {
|
||||
const el = markerHtml(i, false, !!darkRef.current)
|
||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.addTo(map)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
onMarkerClickRef.current?.(item.id)
|
||||
})
|
||||
markersRef.current.set(item.id, marker)
|
||||
})
|
||||
|
||||
// fit bounds to all points
|
||||
if (hasPoints) {
|
||||
const pb = paddingBottom || 50
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: { top: 50, bottom: pb, left: 50, right: 50 },
|
||||
maxZoom: 16,
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 0,
|
||||
})
|
||||
} catch { /* empty bounds */ }
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (popupRef.current) {
|
||||
try { popupRef.current.remove() } catch { /* noop */ }
|
||||
popupRef.current = null
|
||||
}
|
||||
highlightedRef.current = null
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [entries, stableTrail, mapboxStyle, mapboxToken, mapbox3d, mapboxQuality, fullScreen, paddingBottom])
|
||||
|
||||
// external activeMarkerId → highlight + flyTo
|
||||
useEffect(() => {
|
||||
if (!activeMarkerId || !mapRef.current) return
|
||||
const t = setTimeout(() => {
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (!marker || !mapRef.current) return
|
||||
try {
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||
pitch: mapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 500,
|
||||
})
|
||||
} catch { /* map not ready */ }
|
||||
}, 50)
|
||||
return () => clearTimeout(t)
|
||||
}, [activeMarkerId, highlightMarker, mapbox3d, fullScreen])
|
||||
|
||||
if (!mapboxToken) {
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}
|
||||
className="flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6"
|
||||
>
|
||||
<div className="text-sm text-zinc-500">
|
||||
No Mapbox access token configured.<br />
|
||||
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default JourneyMapGL
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||
import type { TrackingMode } from '../../hooks/useGeolocation'
|
||||
|
||||
interface Props {
|
||||
mode: TrackingMode
|
||||
error: string | null
|
||||
onClick: () => void
|
||||
// Offset from the bottom edge — callers push this up above the mobile
|
||||
// bottom nav. Defaults to 20px for desktop.
|
||||
bottomOffset?: number
|
||||
}
|
||||
|
||||
// Three-state FAB. Matches the Apple/Google Maps pattern:
|
||||
// off → outline locate icon
|
||||
// show → filled locate (blue dot is visible on the map)
|
||||
// follow → filled navigation arrow (map follows + rotates with heading)
|
||||
export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) {
|
||||
const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate
|
||||
const isActive = mode !== 'off'
|
||||
const title = error
|
||||
? error
|
||||
: mode === 'off'
|
||||
? 'Show my location'
|
||||
: mode === 'show'
|
||||
? 'Follow my location'
|
||||
: 'Stop following'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: bottomOffset,
|
||||
right: 12,
|
||||
zIndex: 1000,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={mode === 'follow' ? 2.5 : 2} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -278,93 +278,76 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import LocationButton from './LocationButton'
|
||||
|
||||
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||
function LocationTracker() {
|
||||
// Live-location rendering inside the Leaflet map. Subscribes via the
|
||||
// shared useGeolocation hook so the Leaflet and Mapbox variants behave
|
||||
// identically. Heading is shown as a rotated conic SVG when available.
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||
|
||||
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
|
||||
const map = useMap()
|
||||
const [position, setPosition] = useState<[number, number] | null>(null)
|
||||
const [accuracy, setAccuracy] = useState(0)
|
||||
const [tracking, setTracking] = useState(false)
|
||||
const watchId = useRef<number | null>(null)
|
||||
|
||||
const startTracking = useCallback(() => {
|
||||
if (!('geolocation' in navigator)) return
|
||||
setTracking(true)
|
||||
watchId.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
|
||||
setPosition(latlng)
|
||||
setAccuracy(pos.coords.accuracy)
|
||||
},
|
||||
() => setTracking(false),
|
||||
{ enableHighAccuracy: true, maximumAge: 5000 }
|
||||
)
|
||||
}, [])
|
||||
|
||||
const stopTracking = useCallback(() => {
|
||||
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
|
||||
watchId.current = null
|
||||
setTracking(false)
|
||||
setPosition(null)
|
||||
}, [])
|
||||
|
||||
const toggleTracking = useCallback(() => {
|
||||
if (tracking) { stopTracking() } else { startTracking() }
|
||||
}, [tracking, startTracking, stopTracking])
|
||||
|
||||
// Center map on position when first acquired
|
||||
const centered = useRef(false)
|
||||
// When the user is in follow mode, keep the map centred on the dot.
|
||||
// setView (no animation) is what Google Maps does during navigation —
|
||||
// it feels responsive and avoids animation jitter at walking speed.
|
||||
useEffect(() => {
|
||||
if (position && !centered.current) {
|
||||
map.setView(position, 15)
|
||||
centered.current = true
|
||||
}
|
||||
}, [position, map])
|
||||
if (mode !== 'follow' || !position) return
|
||||
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 16), { animate: true, duration: 0.35 }) } catch { /* noop */ }
|
||||
}, [position, mode, map])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
|
||||
// Once, when the user first acquires a fix in "show" mode, pan to it so
|
||||
// they don't have to scroll the map. Subsequent fixes only move the dot.
|
||||
const centeredRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (mode === 'off') { centeredRef.current = false; return }
|
||||
if (!position || centeredRef.current) return
|
||||
try { map.setView([position.lat, position.lng], Math.max(map.getZoom(), 15)) } catch { /* noop */ }
|
||||
centeredRef.current = true
|
||||
}, [position, mode, map])
|
||||
|
||||
if (!position) return null
|
||||
|
||||
const headingIcon = position.heading === null || Number.isNaN(position.heading) ? null : L.divIcon({
|
||||
className: '',
|
||||
iconSize: [60, 60],
|
||||
iconAnchor: [30, 30],
|
||||
html: `<div style="
|
||||
width:60px;height:60px;
|
||||
transform:rotate(${position.heading}deg);transition:transform 120ms ease-out;
|
||||
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
|
||||
border-radius:50%;
|
||||
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
pointer-events:none;
|
||||
"></div>`,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Location button */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
|
||||
}}>
|
||||
<button onClick={toggleTracking} style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
border: 'none', cursor: 'pointer',
|
||||
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.2s, color 0.2s',
|
||||
}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Blue dot + accuracy circle */}
|
||||
{position && (
|
||||
<>
|
||||
{accuracy < 500 && (
|
||||
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
|
||||
)}
|
||||
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
|
||||
</>
|
||||
{position.accuracy < 500 && (
|
||||
<Circle
|
||||
center={[position.lat, position.lng]}
|
||||
radius={position.accuracy}
|
||||
pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.12, weight: 1, opacity: 0.35 }}
|
||||
interactive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pulse animation CSS */}
|
||||
{position && (
|
||||
<style>{`
|
||||
@keyframes location-pulse {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
{headingIcon && (
|
||||
<Marker
|
||||
position={[position.lat, position.lng]}
|
||||
icon={headingIcon}
|
||||
interactive={false}
|
||||
zIndexOffset={900}
|
||||
/>
|
||||
)}
|
||||
<CircleMarker
|
||||
center={[position.lat, position.lng]}
|
||||
radius={8}
|
||||
pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 3 }}
|
||||
interactive={false}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -561,8 +544,15 @@ export const MapView = memo(function MapView({
|
||||
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const locationButtonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full relative">
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
@@ -586,7 +576,7 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<LocationTracker />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
@@ -631,6 +621,13 @@ export const MapView = memo(function MapView({
|
||||
onEndpointClick={onReservationClick}
|
||||
/>
|
||||
</MapContainer>
|
||||
{isMobile && <LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={locationButtonBottom as unknown as number}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
{TooltipOverlay && (
|
||||
<div data-testid="tooltip" style={{
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import { MapViewGL } from './MapViewGL'
|
||||
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MapViewAuto(props: any) {
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||
// so trip planner never shows an empty map due to a missing token.
|
||||
if (provider === 'mapbox-gl' && token) return <MapViewGL {...props} />
|
||||
return <MapView {...props} />
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
|
||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
|
||||
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
try {
|
||||
return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
interface RouteSegment {
|
||||
mid: [number, number]
|
||||
from: [number, number]
|
||||
to: [number, number]
|
||||
walkingText?: string
|
||||
drivingText?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
places: Place[]
|
||||
dayPlaces?: Place[]
|
||||
route?: [number, number][][] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
selectedPlaceId?: number | null
|
||||
onMarkerClick?: (id: number) => void
|
||||
onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
|
||||
onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
|
||||
center?: [number, number]
|
||||
zoom?: number
|
||||
fitKey?: number | null
|
||||
dayOrderMap?: Record<number, number[] | null>
|
||||
leftWidth?: number
|
||||
rightWidth?: number
|
||||
hasInspector?: boolean
|
||||
hasDayDetail?: boolean
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
const size = selected ? 44 : 36
|
||||
const borderColor = selected ? '#111827' : 'white'
|
||||
const borderWidth = selected ? 3 : 2.5
|
||||
const shadow = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||
const bgColor = place.category_color || '#6b7280'
|
||||
|
||||
// The visual circle is `size` + 2*border on each side. To make the
|
||||
// mapbox `anchor: 'center'` land on the real visual middle of the marker
|
||||
// (rather than just the inner content box), the wrapper has to be the
|
||||
// full outer size. If we gave the wrapper only `size`, the border would
|
||||
// bleed outside it and the route lines would appear slightly off.
|
||||
const outer = size + borderWidth * 2
|
||||
|
||||
let badgeHtml = ''
|
||||
if (orderNumbers && orderNumbers.length > 0) {
|
||||
const label = orderNumbers.join(' · ')
|
||||
badgeHtml = `<span style="
|
||||
position:absolute;bottom:-2px;right:-2px;
|
||||
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
|
||||
padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
|
||||
background:rgba(255,255,255,0.94);
|
||||
border:1.5px solid rgba(0,0,0,0.15);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||
box-sizing:border-box;white-space:nowrap;
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
// Do NOT set `position: relative` here — mapbox-gl ships
|
||||
// `.mapboxgl-marker { position: absolute }` and relies on it. An inline
|
||||
// `position: relative` here overrides the class, turns every marker into
|
||||
// a static block element, and stacks them in document order inside the
|
||||
// canvas container. The result looks exactly like "markers drift as the
|
||||
// map zooms" because each marker's transform is then applied relative
|
||||
// to its stacked slot, not to the map viewport.
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`
|
||||
|
||||
const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'))
|
||||
if (hasPhoto) {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
overflow:hidden;background:${bgColor};
|
||||
box-sizing:content-box;
|
||||
">
|
||||
<img src="${photoUrl}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
${badgeHtml}
|
||||
`
|
||||
} else {
|
||||
wrap.innerHTML = `
|
||||
<div style="
|
||||
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
border:${borderWidth}px solid ${borderColor};
|
||||
box-shadow:${shadow};
|
||||
background:${bgColor};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-sizing:content-box;
|
||||
">
|
||||
${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
|
||||
</div>
|
||||
${badgeHtml}
|
||||
`
|
||||
}
|
||||
return wrap
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
onMapContextMenu = null,
|
||||
center = [48.8566, 2.3522],
|
||||
zoom = 10,
|
||||
fitKey = 0,
|
||||
dayOrderMap = {},
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
hasInspector = false,
|
||||
hasDayDetail = false,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
|
||||
const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
onClickRefs.current.map = onMapClick
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
|
||||
// Build/rebuild the map on style/token/3d change
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !mapboxToken) return
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: containerRef.current,
|
||||
style: mapboxStyle,
|
||||
center: [center[1], center[0]],
|
||||
zoom,
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).__trek_map = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (mapbox3d) {
|
||||
// Terrain is only valuable on satellite styles — on clean vector
|
||||
// styles it makes route lines drift off the HTML markers because
|
||||
// the lines snap to DEM height while markers stay at sea level.
|
||||
if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(mapboxStyle)) {
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
addCustom3dBuildings(map, dark)
|
||||
}
|
||||
}
|
||||
|
||||
// Mapbox Standard ships its own DEM-based terrain that kicks in
|
||||
// below zoom 13.7. HTML markers project at sea level, so when the
|
||||
// terrain exaggeration ramps up at lower zooms the markers drift
|
||||
// away from the 3D buildings and route lines they belong to. The
|
||||
// non-satellite Standard style still looks great without terrain,
|
||||
// so flatten it out to keep markers pinned. (Satellite variants
|
||||
// are left alone — the DEM is what gives them their character.)
|
||||
if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: {
|
||||
'line-color': '#111827',
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.9,
|
||||
'line-dasharray': [2, 1.5],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
// gpx geometries source (place.route_geometry)
|
||||
if (!map.getSource('trip-gpx')) {
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-gpx-line',
|
||||
type: 'line',
|
||||
source: 'trip-gpx',
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
|
||||
'line-width': 3.5,
|
||||
'line-opacity': 0.75,
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
map.on('click', (e) => {
|
||||
const t = e.originalEvent.target as HTMLElement
|
||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// In the mapbox-gl map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
const canvas = map.getCanvasContainer()
|
||||
const onAuxDown = (ev: MouseEvent) => {
|
||||
if (ev.button !== 1) return
|
||||
ev.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
|
||||
onClickRefs.current.context?.({
|
||||
latlng: { lat: lngLat.lat, lng: lngLat.lng },
|
||||
originalEvent: ev,
|
||||
})
|
||||
}
|
||||
// Also suppress the browser's native auxclick menu on middle-click.
|
||||
const onAuxClick = (ev: MouseEvent) => {
|
||||
if (ev.button === 1) ev.preventDefault()
|
||||
}
|
||||
canvas.addEventListener('mousedown', onAuxDown)
|
||||
canvas.addEventListener('auxclick', onAuxClick)
|
||||
|
||||
// Drop follow mode if the user pans the map manually — matches the
|
||||
// Apple Maps behaviour where the blue dot stays but the map no longer
|
||||
// chases it until the user taps the button again.
|
||||
map.on('dragstart', () => {
|
||||
setTrackingMode(prev => prev === 'follow' ? 'show' : prev)
|
||||
})
|
||||
|
||||
// Keep HTML markers glued to the terrain / 3D ground. Mapbox projects
|
||||
// HTML markers at altitude=0 (sea level) by default, so as soon as the
|
||||
// style has a terrain DEM (Standard, Standard Satellite, custom terrain)
|
||||
// the markers drift off the places when the camera pitches or zooms —
|
||||
// the buildings rise from DEM height, the marker stays at sea level,
|
||||
// and the pixel offset grows as the perspective changes.
|
||||
//
|
||||
// Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to
|
||||
// project the marker onto the same ground the route line sits on.
|
||||
// We re-apply this every render because DEM tiles stream in async.
|
||||
let lastAltUpdate = 0
|
||||
const syncMarkerAltitudes = () => {
|
||||
const now = performance.now()
|
||||
if (now - lastAltUpdate < 80) return // ~12Hz is plenty
|
||||
lastAltUpdate = now
|
||||
markersRef.current.forEach(marker => {
|
||||
const ll = marker.getLngLat()
|
||||
let alt = 0
|
||||
try {
|
||||
const e = map.queryTerrainElevation([ll.lng, ll.lat])
|
||||
if (typeof e === 'number' && Number.isFinite(e)) alt = e
|
||||
} catch { /* terrain not ready */ }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const curAlt = (ll as any).alt ?? 0
|
||||
if (Math.abs(curAlt - alt) > 0.25) {
|
||||
marker.setLngLat([ll.lng, ll.lat, alt])
|
||||
}
|
||||
})
|
||||
}
|
||||
map.on('render', syncMarkerAltitudes)
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onAuxDown)
|
||||
canvas.removeEventListener('auxclick', onAuxClick)
|
||||
markersRef.current.forEach(m => m.remove())
|
||||
markersRef.current.clear()
|
||||
if (locationMarkerRef.current) {
|
||||
locationMarkerRef.current.destroy()
|
||||
locationMarkerRef.current = null
|
||||
}
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only
|
||||
|
||||
// Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
|
||||
// simultaneous thumb arrivals into one re-render.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
if (!cacheKey) continue
|
||||
const cached = getCached(cacheKey)
|
||||
if (cached?.thumbDataUrl) {
|
||||
setThumb(cacheKey, cached.thumbDataUrl)
|
||||
continue
|
||||
}
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reconcile markers with places + photos. Rebuilds the DOM node when any
|
||||
// visual input changes so photos, selection state and order badges stay
|
||||
// in sync.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const ids = new Set(places.map(p => p.id))
|
||||
|
||||
markersRef.current.forEach((marker, id) => {
|
||||
if (!ids.has(id)) {
|
||||
marker.remove()
|
||||
markersRef.current.delete(id)
|
||||
}
|
||||
})
|
||||
|
||||
places.forEach(place => {
|
||||
if (!place.lat || !place.lng) return
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const selected = place.id === selectedPlaceId
|
||||
const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation()
|
||||
onClickRefs.current.marker?.(place.id)
|
||||
})
|
||||
// Recreate marker each time rather than patching internal state —
|
||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||
const existing = markersRef.current.get(place.id)
|
||||
if (existing) existing.remove()
|
||||
// Default (viewport-aligned) anchors keep the marker parallel to the
|
||||
// screen so its pixel centre lines up with the route line at any
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
// but it rotates the element by the pitch angle and visually offsets
|
||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([place.lng, place.lat])
|
||||
.addTo(map)
|
||||
markersRef.current.set(place.id, m)
|
||||
})
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||
|
||||
// Update route geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {},
|
||||
geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
|
||||
}))
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route])
|
||||
|
||||
// Update GPX geometries
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
const features = places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [{
|
||||
type: 'Feature' as const,
|
||||
properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
|
||||
geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
|
||||
}]
|
||||
} catch { return [] }
|
||||
})
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [places])
|
||||
|
||||
// Fit bounds on fitKey change — matches the Leaflet BoundsController
|
||||
const paddingOpts = useMemo(() => {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
|
||||
const top = 60
|
||||
const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
|
||||
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Also fit when the places collection changes so the initial render
|
||||
// zooms to the trip instead of the default center.
|
||||
const placeBoundsKey = useMemo(
|
||||
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
|
||||
[places]
|
||||
)
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const target = dayPlaces.length > 0 ? dayPlaces : places
|
||||
const valid = target.filter(p => p.lat && p.lng)
|
||||
if (valid.length === 0) return
|
||||
const bounds = new mapboxgl.LngLatBounds()
|
||||
valid.forEach(p => bounds.extend([p.lng, p.lat]))
|
||||
const run = () => {
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
if (map.loaded()) run()
|
||||
else map.once('load', run)
|
||||
}, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !selectedPlaceId) return
|
||||
const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
|
||||
if (!target?.lat || !target?.lng) return
|
||||
try {
|
||||
map.flyTo({
|
||||
center: [target.lng, target.lat],
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// External center/zoom prop changes — jump without animation
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
try { map.jumpTo({ center: [center[1], center[0]], zoom }) } catch { /* noop */ }
|
||||
}, [center[0], center[1]]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Blue dot rendering + follow-mode camera. Attach the marker lazily the
|
||||
// first time a fix arrives so the layers sit on top of everything else
|
||||
// added so far, and destroy it when tracking is turned off.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
if (trackingMode === 'off') {
|
||||
if (locationMarkerRef.current) {
|
||||
locationMarkerRef.current.update(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!userPosition) return
|
||||
const apply = () => {
|
||||
if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
|
||||
locationMarkerRef.current.update(userPosition)
|
||||
if (trackingMode === 'follow') {
|
||||
// easeTo is gentler than flyTo for continuous updates
|
||||
try {
|
||||
map.easeTo({
|
||||
center: [userPosition.lng, userPosition.lat],
|
||||
bearing: userPosition.heading ?? map.getBearing(),
|
||||
zoom: Math.max(map.getZoom(), 16),
|
||||
duration: 350,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}
|
||||
if (map.loaded()) apply()
|
||||
else map.once('load', apply)
|
||||
}, [userPosition, trackingMode])
|
||||
|
||||
if (!mapboxToken) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
|
||||
<div className="text-sm text-zinc-500">
|
||||
No Mapbox access token configured.<br />
|
||||
<span className="text-xs">Settings → Map → Mapbox GL</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative">
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
{isMobile && (
|
||||
<LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={buttonBottom as unknown as number}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import type { GeoPosition } from '../../hooks/useGeolocation'
|
||||
|
||||
// Build the DOM element that backs the mapbox Marker. We animate the
|
||||
// heading cone via a CSS rotation so the DOM stays stable across updates
|
||||
// and mapbox doesn't get confused about which element to position.
|
||||
function buildLocationEl(): { root: HTMLDivElement; cone: HTMLDivElement } {
|
||||
const root = document.createElement('div')
|
||||
root.style.cssText = 'width:28px;height:28px;position:relative;pointer-events:none;'
|
||||
// Accuracy pulse behind the dot
|
||||
const pulse = document.createElement('div')
|
||||
pulse.style.cssText = `
|
||||
position:absolute;inset:-14px;border-radius:50%;
|
||||
background:#3b82f6;opacity:0.25;
|
||||
animation:trek-location-pulse 2s ease-out infinite;
|
||||
`
|
||||
// Heading cone (conic gradient fan)
|
||||
const cone = document.createElement('div')
|
||||
cone.style.cssText = `
|
||||
position:absolute;left:50%;top:50%;width:60px;height:60px;
|
||||
transform:translate(-50%,-50%) rotate(0deg);
|
||||
background:conic-gradient(from -30deg, rgba(59,130,246,0) 0deg, rgba(59,130,246,0.35) 15deg, rgba(59,130,246,0) 60deg, rgba(59,130,246,0) 360deg);
|
||||
border-radius:50%;
|
||||
mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
-webkit-mask:radial-gradient(circle, transparent 12px, black 13px);
|
||||
transition:transform 0.12s ease-out;
|
||||
display:none;
|
||||
`
|
||||
// Blue dot
|
||||
const dot = document.createElement('div')
|
||||
dot.style.cssText = `
|
||||
position:absolute;left:50%;top:50%;
|
||||
transform:translate(-50%,-50%);
|
||||
width:18px;height:18px;border-radius:50%;
|
||||
background:#3b82f6;border:3px solid white;
|
||||
box-shadow:0 0 0 1px rgba(0,0,0,0.15), 0 2px 6px rgba(0,0,0,0.3);
|
||||
`
|
||||
root.appendChild(pulse)
|
||||
root.appendChild(cone)
|
||||
root.appendChild(dot)
|
||||
return { root, cone }
|
||||
}
|
||||
|
||||
// Inject the pulse keyframes once per document so the animation is
|
||||
// available for every map instance.
|
||||
function ensurePulseStyle() {
|
||||
if (document.getElementById('trek-location-style')) return
|
||||
const s = document.createElement('style')
|
||||
s.id = 'trek-location-style'
|
||||
s.textContent = `
|
||||
@keyframes trek-location-pulse {
|
||||
0% { transform: scale(0.6); opacity: 0.35; }
|
||||
70% { transform: scale(1.6); opacity: 0; }
|
||||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
export interface LocationMarkerHandle {
|
||||
update: (p: GeoPosition | null) => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
// Creates (or reuses) a location marker + accuracy circle on the given
|
||||
// mapbox map. Returns a handle the caller uses to push position updates
|
||||
// and clean up. Keeps its own DOM element and GeoJSON source so it can
|
||||
// coexist with the regular trip markers.
|
||||
export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
|
||||
ensurePulseStyle()
|
||||
const { root, cone } = buildLocationEl()
|
||||
const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
|
||||
|
||||
const ensureAccuracyLayer = () => {
|
||||
if (map.getSource('trek-location-accuracy')) return
|
||||
try {
|
||||
map.addSource('trek-location-accuracy', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
})
|
||||
// Draw the accuracy ring as a geographic polygon: it's a real geodesic
|
||||
// circle defined in meters, so mapbox automatically scales it with
|
||||
// zoom the way Apple/Google Maps does — always the same real-world
|
||||
// size regardless of viewport.
|
||||
map.addLayer({
|
||||
id: 'trek-location-accuracy',
|
||||
type: 'fill',
|
||||
source: 'trek-location-accuracy',
|
||||
paint: {
|
||||
'fill-color': '#3b82f6',
|
||||
'fill-opacity': 0.14,
|
||||
'fill-outline-color': '#3b82f6',
|
||||
},
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
// Build a polygon approximating a geodesic circle around (lng, lat)
|
||||
// with the given radius in meters. 48 segments is plenty for a smooth
|
||||
// edge without paying much CPU per fix.
|
||||
const geodesicCircle = (lng: number, lat: number, radiusMeters: number): number[][] => {
|
||||
const earth = 6378137
|
||||
const d = radiusMeters / earth
|
||||
const lat1 = lat * Math.PI / 180
|
||||
const lng1 = lng * Math.PI / 180
|
||||
const coords: number[][] = []
|
||||
const segments = 48
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const bearing = (i / segments) * 2 * Math.PI
|
||||
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(bearing))
|
||||
const lng2 = lng1 + Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(d) * Math.cos(lat1),
|
||||
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2),
|
||||
)
|
||||
coords.push([lng2 * 180 / Math.PI, lat2 * 180 / Math.PI])
|
||||
}
|
||||
return coords
|
||||
}
|
||||
|
||||
const setAccuracy = (p: GeoPosition) => {
|
||||
const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
if (!p.accuracy || p.accuracy < 1) {
|
||||
src.setData({ type: 'FeatureCollection', features: [] })
|
||||
return
|
||||
}
|
||||
const ring = geodesicCircle(p.lng, p.lat, p.accuracy)
|
||||
src.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {},
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
let lastPosRef: GeoPosition | null = null
|
||||
|
||||
if (map.loaded()) ensureAccuracyLayer()
|
||||
else map.once('load', ensureAccuracyLayer)
|
||||
|
||||
const handle: LocationMarkerHandle = {
|
||||
update: (p) => {
|
||||
lastPosRef = p
|
||||
if (!p) {
|
||||
marker.remove()
|
||||
const src = map.getSource('trek-location-accuracy') as mapboxgl.GeoJSONSource | undefined
|
||||
src?.setData({ type: 'FeatureCollection', features: [] })
|
||||
return
|
||||
}
|
||||
marker.setLngLat([p.lng, p.lat])
|
||||
if (!marker.getElement().parentElement) marker.addTo(map)
|
||||
if (p.heading !== null && !Number.isNaN(p.heading)) {
|
||||
cone.style.display = 'block'
|
||||
cone.style.transform = `translate(-50%,-50%) rotate(${p.heading}deg)`
|
||||
} else {
|
||||
cone.style.display = 'none'
|
||||
}
|
||||
setAccuracy(p)
|
||||
},
|
||||
destroy: () => {
|
||||
try { marker.remove() } catch { /* noop */ }
|
||||
try {
|
||||
if (map.getLayer('trek-location-accuracy')) map.removeLayer('trek-location-accuracy')
|
||||
if (map.getSource('trek-location-accuracy')) map.removeSource('trek-location-accuracy')
|
||||
} catch { /* noop */ }
|
||||
},
|
||||
}
|
||||
|
||||
return handle
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
|
||||
// "mapbox/standard" and "mapbox/standard-satellite" ship their own 3D
|
||||
// buildings and terrain. For every other style we inject a fill-extrusion
|
||||
// layer against the classic `composite` vector source so the user still
|
||||
// gets real 3D buildings (not just a tilted 2D view) when they toggle 3D.
|
||||
export function isStandardFamily(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/standard' || style === 'mapbox://styles/mapbox/standard-satellite'
|
||||
}
|
||||
|
||||
// Terrain is only genuinely useful for the satellite imagery styles — on
|
||||
// clean flat styles like streets/light/dark it nudges route lines onto
|
||||
// the DEM while our HTML markers stay at Z=0, which causes the visible
|
||||
// offset when the map is pitched. Restrict terrain to satellite.
|
||||
export function wantsTerrain(style: string): boolean {
|
||||
return style === 'mapbox://styles/mapbox/satellite-v9'
|
||||
|| style === 'mapbox://styles/mapbox/satellite-streets-v12'
|
||||
}
|
||||
|
||||
// 3D can be added to every style now — the standard family has it built-in
|
||||
// and for everything else we either reuse the style's own `composite`
|
||||
// building layer or attach the public `mapbox-streets-v8` tileset as an
|
||||
// extra source (needed for pure satellite, which has no vector data).
|
||||
export function supportsCustom3d(style: string): boolean {
|
||||
return !isStandardFamily(style)
|
||||
}
|
||||
|
||||
// Add a 3D buildings extrusion layer to a non-Standard Mapbox style. For
|
||||
// the pure satellite style we lazily attach `mapbox-streets-v8` as a
|
||||
// fallback source so real building volumes sit on top of the imagery —
|
||||
// the Apple Maps-style "3D satellite" look the user asked for.
|
||||
export function addCustom3dBuildings(map: mapboxgl.Map, dark: boolean) {
|
||||
if (map.getLayer('trek-3d-buildings')) return
|
||||
const baseColor = dark ? '#3b3b3f' : '#cfd2d6'
|
||||
|
||||
// Styles without a `composite` source (pure satellite) need a fallback
|
||||
// vector tileset for building geometry.
|
||||
let sourceId = 'composite'
|
||||
if (!map.getSource('composite')) {
|
||||
sourceId = 'mapbox-streets-v8'
|
||||
if (!map.getSource(sourceId)) {
|
||||
try {
|
||||
map.addSource(sourceId, { type: 'vector', url: 'mapbox://mapbox.mapbox-streets-v8' })
|
||||
} catch { return }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Place extrusions below the first label layer so text stays readable.
|
||||
const layers = map.getStyle()?.layers || []
|
||||
const firstSymbolId = layers.find(l => l.type === 'symbol')?.id
|
||||
map.addLayer({
|
||||
id: 'trek-3d-buildings',
|
||||
source: sourceId,
|
||||
'source-layer': 'building',
|
||||
filter: ['==', 'extrude', 'true'],
|
||||
type: 'fill-extrusion',
|
||||
minzoom: 14,
|
||||
paint: {
|
||||
'fill-extrusion-color': baseColor,
|
||||
'fill-extrusion-height': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
14, 0,
|
||||
15.5, ['coalesce', ['get', 'height'], 0],
|
||||
],
|
||||
'fill-extrusion-base': [
|
||||
'interpolate', ['linear'], ['zoom'],
|
||||
14, 0,
|
||||
15.5, ['coalesce', ['get', 'min_height'], 0],
|
||||
],
|
||||
'fill-extrusion-opacity': 0.85,
|
||||
},
|
||||
}, firstSymbolId)
|
||||
} catch { /* building source-layer unavailable */ }
|
||||
}
|
||||
|
||||
// Terrain + sky that works against any style that has the DEM source.
|
||||
// The Standard family already handles terrain internally, skip there.
|
||||
export function addTerrainAndSky(map: mapboxgl.Map) {
|
||||
try {
|
||||
if (!map.getSource('mapbox-dem')) {
|
||||
map.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14,
|
||||
})
|
||||
}
|
||||
map.setTerrain({ source: 'mapbox-dem', exaggeration: 1.2 })
|
||||
if (!map.getLayer('sky')) {
|
||||
map.addLayer({
|
||||
id: 'sky',
|
||||
type: 'sky',
|
||||
paint: {
|
||||
'sky-type': 'atmosphere',
|
||||
'sky-atmosphere-sun-intensity': 15,
|
||||
} as unknown as mapboxgl.SkyLayerSpecification['paint'],
|
||||
})
|
||||
}
|
||||
} catch { /* style doesn't support terrain */ }
|
||||
}
|
||||
@@ -270,6 +270,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const inputRef = useRef(null)
|
||||
const dragDataRef = useRef(null)
|
||||
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||
// Remember which assignment we last auto-scrolled into view so we don't
|
||||
// keep yanking the user back whenever they scroll away while the same
|
||||
// place stays selected.
|
||||
const lastAutoScrolledIdRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
// Reset the scroll-lock whenever selection moves, so the next selected
|
||||
// row triggers a fresh scroll-into-view on its ref.
|
||||
if (!selectedAssignmentId && !selectedPlaceId) {
|
||||
lastAutoScrolledIdRef.current = null
|
||||
}
|
||||
}, [selectedAssignmentId, selectedPlaceId])
|
||||
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
@@ -1131,7 +1142,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '11px 14px 11px 16px',
|
||||
cursor: 'pointer',
|
||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-tertiary)' : 'transparent'),
|
||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : 'transparent'),
|
||||
transition: 'background 0.12s',
|
||||
userSelect: 'none',
|
||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||
@@ -1439,6 +1450,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
}
|
||||
}}
|
||||
ref={el => {
|
||||
// Auto-scroll the selected row into view — but only on
|
||||
// the transition "just became selected". Once we've
|
||||
// scrolled for this assignment id, we won't scroll
|
||||
// again until selection actually moves somewhere else.
|
||||
if (el && isPlaceSelected && lastAutoScrolledIdRef.current !== assignment.id) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const nearTop = rect.top < 80
|
||||
const nearBottom = rect.bottom > window.innerHeight - 80
|
||||
if (nearTop || nearBottom) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
lastAutoScrolledIdRef.current = assignment.id
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
@@ -1469,7 +1495,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
cursor: 'pointer',
|
||||
background: lockedIds.has(assignment.id)
|
||||
? 'rgba(220,38,38,0.08)'
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
: isPlaceSelected ? 'var(--bg-selected)' : 'transparent',
|
||||
borderLeft: lockedIds.has(assignment.id)
|
||||
? '3px solid #dc2626'
|
||||
: '3px solid transparent',
|
||||
|
||||
@@ -135,7 +135,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' })
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
|
||||
@@ -123,12 +123,12 @@ describe('MapSettingsTab', () => {
|
||||
});
|
||||
render(<MapSettingsTab />);
|
||||
await user.click(screen.getByText('Save Map'));
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
map_tile_url: '',
|
||||
default_lat: 48.8566,
|
||||
default_lng: 2.3522,
|
||||
default_zoom: 10,
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-013: Save Map button shows spinner while saving', async () => {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Map, Save } from 'lucide-react'
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { Map, Save, Layers, Box, ChevronDown, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import MapboxPreview from './MapboxPreview'
|
||||
import Section from './Section'
|
||||
import ToggleSwitch from './ToggleSwitch'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
interface MapPreset {
|
||||
@@ -21,18 +23,136 @@ const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
interface StylePreset {
|
||||
name: string
|
||||
url: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const MAPBOX_STYLE_PRESETS: StylePreset[] = [
|
||||
{ name: 'Mapbox Standard', url: 'mapbox://styles/mapbox/standard', tags: ['3D', 'Apple-like'] },
|
||||
{ name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
|
||||
{ name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
|
||||
{ name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
|
||||
{ name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
|
||||
]
|
||||
|
||||
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
|
||||
// user scanning the list can spot 3D / Satellite / Apple-like styles.
|
||||
const TAG_STYLES: Record<string, string> = {
|
||||
'3D': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
'2D': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||
'Satellite': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
'Apple-like': 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
'Modern': 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
'Dark': 'bg-zinc-800 text-zinc-100 dark:bg-zinc-900 dark:text-zinc-300',
|
||||
'Minimal': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
|
||||
'Hillshading': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
'Terrain': 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300',
|
||||
'Realistic': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
'Navigation': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300',
|
||||
'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300',
|
||||
'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||
}
|
||||
|
||||
function TagChip({ tag }: { tag: string }) {
|
||||
const cls = TAG_STYLES[tag] || 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300'
|
||||
return (
|
||||
<span className={`text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded leading-none ${cls}`}>
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StyleDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [open])
|
||||
|
||||
const selected = MAPBOX_STYLE_PRESETS.find(p => p.url === value)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg text-sm bg-white dark:bg-slate-900 hover:border-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-slate-900 dark:text-white truncate">
|
||||
{selected ? selected.name : 'Select a Mapbox style'}
|
||||
</span>
|
||||
{selected && (
|
||||
<span className="flex items-center gap-1 flex-shrink-0">
|
||||
{selected.tags.map(t => <TagChip key={t} tag={t} />)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown size={14} className="flex-shrink-0 text-slate-400" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-20 mt-1 w-full max-h-80 overflow-auto rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg py-1">
|
||||
{MAPBOX_STYLE_PRESETS.map(preset => {
|
||||
const isActive = preset.url === value
|
||||
return (
|
||||
<button
|
||||
key={preset.url}
|
||||
type="button"
|
||||
onClick={() => { onChange(preset.url); setOpen(false) }}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-800 ${isActive ? 'bg-slate-50 dark:bg-slate-800' : ''}`}
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-slate-900 dark:text-white font-medium">{preset.name}</span>
|
||||
{preset.tags.map(t => <TagChip key={t} tag={t} />)}
|
||||
</span>
|
||||
{isActive && <Check size={14} className="flex-shrink-0 text-slate-900 dark:text-white" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Provider = 'leaflet' | 'mapbox-gl'
|
||||
|
||||
export default function MapSettingsTab(): React.ReactElement {
|
||||
const { settings, updateSettings } = useSettingsStore()
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [provider, setProvider] = useState<Provider>((settings.map_provider as Provider) || 'leaflet')
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '')
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const [mapbox3d, setMapbox3d] = useState<boolean>(settings.mapbox_3d_enabled !== false)
|
||||
const [mapboxQuality, setMapboxQuality] = useState<boolean>(settings.mapbox_quality_mode === true)
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
useEffect(() => {
|
||||
setProvider((settings.map_provider as Provider) || 'leaflet')
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
setMapboxToken(settings.mapbox_access_token || '')
|
||||
setMapboxStyle(settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
setMapbox3d(settings.mapbox_3d_enabled !== false)
|
||||
setMapboxQuality(settings.mapbox_quality_mode === true)
|
||||
setDefaultLat(settings.default_lat || 48.8566)
|
||||
setDefaultLng(settings.default_lng || 2.3522)
|
||||
setDefaultZoom(settings.default_zoom || 10)
|
||||
@@ -67,7 +187,12 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
setSaving(true)
|
||||
try {
|
||||
await updateSettings({
|
||||
map_provider: provider,
|
||||
map_tile_url: mapTileUrl,
|
||||
mapbox_access_token: mapboxToken,
|
||||
mapbox_style: mapboxStyle,
|
||||
mapbox_3d_enabled: mapbox3d,
|
||||
mapbox_quality_mode: mapboxQuality,
|
||||
default_lat: parseFloat(String(defaultLat)),
|
||||
default_lng: parseFloat(String(defaultLng)),
|
||||
default_zoom: parseInt(String(defaultZoom)),
|
||||
@@ -80,28 +205,155 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// 3D is available on every style now — pure satellite uses the
|
||||
// mapbox-streets-v8 tileset as a fallback building source.
|
||||
const supports3d = true
|
||||
|
||||
return (
|
||||
<Section title={t('settings.map')} icon={Map}>
|
||||
{/* Provider picker — big cards so the choice is obvious */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Map Provider</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvider('leaflet')}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||
provider === 'leaflet'
|
||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Layers size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Leaflet</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">Classic 2D, any raster tiles</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProvider('mapbox-gl')}
|
||||
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
|
||||
provider === 'mapbox-gl'
|
||||
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
|
||||
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<span className="absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
Experimental
|
||||
</span>
|
||||
<Box size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">Mapbox GL</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">Vector tiles, 3D buildings & terrain</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
Affects Trip Planner and Journey maps. Atlas always uses Leaflet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Leaflet settings */}
|
||||
{provider === 'leaflet' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mapbox GL settings */}
|
||||
{provider === 'mapbox-gl' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Mapbox Access Token</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e) => setMapboxToken(e.target.value)}
|
||||
placeholder="pk.eyJ1Ijoi..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Public token (pk.*) from{' '}
|
||||
<a href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noreferrer" className="underline">
|
||||
mapbox.com → Access tokens
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Map Style</label>
|
||||
<div className="mb-2">
|
||||
<StyleDropdown value={mapboxStyle} onChange={setMapboxStyle} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e) => setMapboxStyle(e.target.value)}
|
||||
placeholder="mapbox://styles/mapbox/standard"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Preset or your own <code className="text-[11px]">mapbox://styles/USER/ID</code> URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||
supports3d
|
||||
? 'border-slate-200 dark:border-slate-700'
|
||||
: 'border-slate-200 opacity-60 dark:border-slate-700'
|
||||
}`}>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white">3D Buildings & Terrain</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Pitch + real 3D building extrusions — works on every style, including satellite.
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
on={mapbox3d && supports3d}
|
||||
onToggle={() => { if (supports3d) setMapbox3d(!mapbox3d) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-white flex items-center gap-2">
|
||||
High Quality Mode
|
||||
<span className="text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
|
||||
Experimental
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Antialiasing + globe projection for sharper edges and a realistic world view.{' '}
|
||||
<span className="text-amber-600 dark:text-amber-400">May impact performance on lower-end devices.</span>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={mapboxQuality} onToggle={() => setMapboxQuality(!mapboxQuality)} />
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<strong className="text-slate-600 dark:text-slate-300">Tip:</strong> right-click and drag to rotate/pitch the map. Middle-click to add a place (right-click is reserved for rotation).
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default map position — applies regardless of provider */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
|
||||
@@ -109,7 +361,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLat}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLat(e.target.value)}
|
||||
onChange={(e) => setDefaultLat(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
@@ -119,7 +371,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLng}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDefaultLng(e.target.value)}
|
||||
onChange={(e) => setDefaultLng(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
@@ -127,25 +379,40 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
|
||||
<div>
|
||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
places: mapPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: handleMapClick,
|
||||
onMapContextMenu: null,
|
||||
center: [settings.default_lat, settings.default_lng],
|
||||
zoom: defaultZoom,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})}
|
||||
{provider === 'mapbox-gl' ? (
|
||||
<MapboxPreview
|
||||
token={mapboxToken}
|
||||
style={mapboxStyle}
|
||||
lat={parseFloat(String(defaultLat)) || 48.8566}
|
||||
lng={parseFloat(String(defaultLng)) || 2.3522}
|
||||
// Zoom in close so the style's character (3D buildings,
|
||||
// satellite texture, label density) is immediately visible.
|
||||
zoom={Math.max(parseInt(String(defaultZoom)) || 10, 16)}
|
||||
enable3d={mapbox3d && supports3d}
|
||||
quality={mapboxQuality}
|
||||
onClick={(ll) => { setDefaultLat(ll.lat); setDefaultLng(ll.lng) }}
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
React.createElement(MapView as any, {
|
||||
places: mapPlaces,
|
||||
dayPlaces: [],
|
||||
route: null,
|
||||
routeSegments: null,
|
||||
selectedPlaceId: null,
|
||||
onMarkerClick: null,
|
||||
onMapClick: handleMapClick,
|
||||
onMapContextMenu: null,
|
||||
center: [settings.default_lat, settings.default_lng],
|
||||
zoom: defaultZoom,
|
||||
tileUrl: mapTileUrl,
|
||||
fitKey: null,
|
||||
dayOrderMap: [],
|
||||
leftWidth: 0,
|
||||
rightWidth: 0,
|
||||
hasInspector: false,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
|
||||
interface Props {
|
||||
token: string
|
||||
style: string
|
||||
lat: number
|
||||
lng: number
|
||||
zoom: number
|
||||
enable3d: boolean
|
||||
quality?: boolean
|
||||
onClick?: (latlng: { lat: number; lng: number }) => void
|
||||
}
|
||||
|
||||
export default function MapboxPreview({ token, style, lat, lng, zoom, enable3d, quality = false, onClick }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const onClickRef = useRef(onClick)
|
||||
onClickRef.current = onClick
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !token) return
|
||||
mapboxgl.accessToken = token
|
||||
|
||||
const map = new mapboxgl.Map({
|
||||
container: containerRef.current,
|
||||
style,
|
||||
center: [lng, lat],
|
||||
zoom,
|
||||
pitch: enable3d ? 45 : 0,
|
||||
attributionControl: true,
|
||||
antialias: quality,
|
||||
projection: quality ? 'globe' : 'mercator',
|
||||
})
|
||||
mapRef.current = map
|
||||
|
||||
map.on('load', () => {
|
||||
if (enable3d) {
|
||||
if (!isStandardFamily(style)) addTerrainAndSky(map)
|
||||
if (supportsCustom3d(style)) {
|
||||
const dark = document.documentElement.classList.contains('dark')
|
||||
addCustom3dBuildings(map, dark)
|
||||
}
|
||||
}
|
||||
if (style === 'mapbox://styles/mapbox/standard') {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
})
|
||||
|
||||
map.on('click', (e) => {
|
||||
onClickRef.current?.({ lat: e.lngLat.lat, lng: e.lngLat.lng })
|
||||
})
|
||||
|
||||
return () => {
|
||||
try { map.remove() } catch { /* noop */ }
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [token, style, enable3d, quality])
|
||||
|
||||
// Recenter without rebuilding the map when lat/lng/zoom change externally
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return
|
||||
try { mapRef.current.jumpTo({ center: [lng, lat], zoom }) } catch { /* noop */ }
|
||||
}, [lat, lng, zoom])
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-slate-100 dark:bg-slate-800 text-xs text-slate-500 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||
Enter a Mapbox access token to preview
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', borderRadius: '8px', overflow: 'hidden' }} />
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const isTestEnv = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
|
||||
|
||||
// Zählt beim Mount von 0 auf target hoch. Feste Dauer mit ease-out-quint.
|
||||
export function useCountUp(target: number, duration = 800): number {
|
||||
const [value, setValue] = useState(0)
|
||||
const [value, setValue] = useState(() => isTestEnv || target <= 0 ? target : 0)
|
||||
const startRef = useRef<number | null>(null)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
|
||||
const isJsdom = typeof navigator !== 'undefined' && /jsdom/i.test(navigator.userAgent ?? '')
|
||||
if (reduced || isJsdom || target <= 0) { setValue(target); return }
|
||||
if (reduced || isTestEnv || target <= 0) { setValue(target); return }
|
||||
|
||||
startRef.current = null
|
||||
const step = (now: number) => {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Permission-gated orientation listener with iOS support. iOS 13+ requires
|
||||
// an explicit user gesture to request permission, so the caller triggers
|
||||
// this from the "enable location" button click.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type DeviceOrientationEventIOS = typeof DeviceOrientationEvent & { requestPermission?: () => Promise<'granted' | 'denied'> }
|
||||
|
||||
export interface GeoPosition {
|
||||
lat: number
|
||||
lng: number
|
||||
accuracy: number // meters
|
||||
heading: number | null // 0-360°, null when unavailable (stationary, indoor, no sensor)
|
||||
speed: number | null
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type TrackingMode = 'off' | 'show' | 'follow'
|
||||
|
||||
export interface UseGeolocationReturn {
|
||||
position: GeoPosition | null
|
||||
mode: TrackingMode
|
||||
error: string | null
|
||||
/** Toggle through off → show → follow → off. Also triggers iOS orientation permission on first call. */
|
||||
cycleMode: () => Promise<void>
|
||||
/** Force-set mode. Accepts a function for derived updates like `prev => prev === 'follow' ? 'show' : prev`. */
|
||||
setMode: (m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => void
|
||||
}
|
||||
|
||||
// Keep a tiny EMA on heading so the compass cone doesn't jitter on every
|
||||
// device orientation event. Mobile sensors fire at 60Hz and raw readings
|
||||
// swing ±5° even when the phone is still — smoothing to ~0.25 weight
|
||||
// gives a stable-but-responsive needle.
|
||||
function smoothAngle(prev: number | null, next: number, alpha = 0.25): number {
|
||||
if (prev === null) return next
|
||||
// Take the shortest angular distance so we don't lerp the long way around
|
||||
let delta = next - prev
|
||||
if (delta > 180) delta -= 360
|
||||
if (delta < -180) delta += 360
|
||||
return (prev + delta * alpha + 360) % 360
|
||||
}
|
||||
|
||||
export function useGeolocation(): UseGeolocationReturn {
|
||||
const [position, setPosition] = useState<GeoPosition | null>(null)
|
||||
const [mode, setModeState] = useState<TrackingMode>('off')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const watchIdRef = useRef<number | null>(null)
|
||||
const orientationHandlerRef = useRef<((e: DeviceOrientationEvent) => void) | null>(null)
|
||||
const headingRef = useRef<number | null>(null)
|
||||
|
||||
const stopWatch = useCallback(() => {
|
||||
if (watchIdRef.current !== null) {
|
||||
try { navigator.geolocation.clearWatch(watchIdRef.current) } catch { /* noop */ }
|
||||
watchIdRef.current = null
|
||||
}
|
||||
if (orientationHandlerRef.current) {
|
||||
window.removeEventListener('deviceorientationabsolute', orientationHandlerRef.current as EventListener)
|
||||
window.removeEventListener('deviceorientation', orientationHandlerRef.current as EventListener)
|
||||
orientationHandlerRef.current = null
|
||||
}
|
||||
headingRef.current = null
|
||||
}, [])
|
||||
|
||||
const startWatch = useCallback(async () => {
|
||||
if (!('geolocation' in navigator)) {
|
||||
setError('Geolocation is not supported in this browser')
|
||||
return false
|
||||
}
|
||||
setError(null)
|
||||
|
||||
// iOS: ask for orientation permission up front; on Android and desktop
|
||||
// no prompt is needed and the method is undefined.
|
||||
const DOE = (window.DeviceOrientationEvent || {}) as DeviceOrientationEventIOS
|
||||
if (typeof DOE.requestPermission === 'function') {
|
||||
try {
|
||||
const res = await DOE.requestPermission()
|
||||
if (res !== 'granted') {
|
||||
// Permission denied — we still enable location, just no heading cone.
|
||||
}
|
||||
} catch { /* older webkit throws — ignore and proceed */ }
|
||||
}
|
||||
|
||||
// Device orientation → compass heading. `alpha` is rotation around the
|
||||
// Z-axis (0 = facing magnetic north on most devices). The webkit-only
|
||||
// `webkitCompassHeading` is already geographic north + clockwise, so
|
||||
// prefer it when available.
|
||||
const onOrientation = (e: DeviceOrientationEvent) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ev = e as any
|
||||
let heading: number | null = null
|
||||
if (typeof ev.webkitCompassHeading === 'number') {
|
||||
heading = ev.webkitCompassHeading
|
||||
} else if (e.absolute && typeof e.alpha === 'number') {
|
||||
// alpha is CCW from North; convert to CW heading
|
||||
heading = (360 - e.alpha) % 360
|
||||
} else if (typeof e.alpha === 'number') {
|
||||
// Non-absolute orientation: better than nothing but drifts over time
|
||||
heading = (360 - e.alpha) % 360
|
||||
}
|
||||
if (heading === null || Number.isNaN(heading)) return
|
||||
headingRef.current = smoothAngle(headingRef.current, heading)
|
||||
// Merge into position without triggering a refetch
|
||||
setPosition(p => p ? { ...p, heading: headingRef.current } : p)
|
||||
}
|
||||
orientationHandlerRef.current = onOrientation
|
||||
// Prefer "absolute" which is tied to magnetic north; fall back to plain.
|
||||
window.addEventListener('deviceorientationabsolute', onOrientation as EventListener)
|
||||
window.addEventListener('deviceorientation', onOrientation as EventListener)
|
||||
|
||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
setPosition({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy,
|
||||
// GPS heading is reliable when moving; keep compass reading
|
||||
// otherwise so the arrow still points correctly when stationary.
|
||||
heading: pos.coords.heading ?? headingRef.current,
|
||||
speed: pos.coords.speed ?? null,
|
||||
timestamp: pos.timestamp,
|
||||
})
|
||||
},
|
||||
(err) => {
|
||||
setError(err.message || 'Location unavailable')
|
||||
// Stay subscribed so a later fix can still recover (e.g. GPS
|
||||
// lock takes a while indoors). Only fully stop on permission denial.
|
||||
if (err.code === err.PERMISSION_DENIED) {
|
||||
stopWatch()
|
||||
setModeState('off')
|
||||
}
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 2000,
|
||||
timeout: 15000,
|
||||
}
|
||||
)
|
||||
return true
|
||||
}, [stopWatch])
|
||||
|
||||
const setMode = useCallback((m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {
|
||||
setModeState(prev => {
|
||||
const next = typeof m === 'function' ? m(prev) : m
|
||||
if (next === 'off') {
|
||||
stopWatch()
|
||||
setPosition(null)
|
||||
} else if (watchIdRef.current === null) {
|
||||
// started externally but no watch yet — start it
|
||||
startWatch()
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [startWatch, stopWatch])
|
||||
|
||||
const cycleMode = useCallback(async () => {
|
||||
if (mode === 'off') {
|
||||
const ok = await startWatch()
|
||||
if (ok) setModeState('show')
|
||||
} else if (mode === 'show') {
|
||||
setModeState('follow')
|
||||
} else {
|
||||
setModeState('off')
|
||||
stopWatch()
|
||||
setPosition(null)
|
||||
}
|
||||
}, [mode, startWatch, stopWatch])
|
||||
|
||||
useEffect(() => stopWatch, [stopWatch])
|
||||
|
||||
return { position, mode, error, cycleMode, setMode }
|
||||
}
|
||||
@@ -1992,6 +1992,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'الإجازة',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'الطقس',
|
||||
'oauth.scope.group.journey': 'مذكرة السفر',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'عرض الرحلات وخطط السفر',
|
||||
@@ -2042,6 +2043,12 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'البحث عن مواقع وحل عناوين الخرائط والترميز الجغرافي العكسي للإحداثيات',
|
||||
'oauth.scope.weather:read.label': 'توقعات الطقس',
|
||||
'oauth.scope.weather:read.description': 'جلب توقعات الطقس لمواقع الرحلة وتواريخها',
|
||||
'oauth.scope.journey:read.label': 'عرض مذكرات السفر',
|
||||
'oauth.scope.journey:read.description': 'قراءة مذكرات السفر والمدخلات وقائمة المساهمين',
|
||||
'oauth.scope.journey:write.label': 'إدارة مذكرات السفر',
|
||||
'oauth.scope.journey:write.description': 'إنشاء مذكرات السفر وتحديثها وحذفها وإدخالاتها',
|
||||
'oauth.scope.journey:share.label': 'إدارة روابط مذكرات السفر',
|
||||
'oauth.scope.journey:share.description': 'إنشاء روابط مشاركة عامة لمذكرات السفر وتحديثها وإلغاؤها',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'مرحبًا بك في TREK',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Férias',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Clima',
|
||||
'oauth.scope.group.journey': 'Jornada',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Ver viagens e itinerários',
|
||||
@@ -2245,6 +2246,12 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Pesquisar locais, resolver URLs de mapa e geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsão do tempo',
|
||||
'oauth.scope.weather:read.description': 'Obter previsão do tempo para locais e datas da viagem',
|
||||
'oauth.scope.journey:read.label': 'Ver jornadas',
|
||||
'oauth.scope.journey:read.description': 'Ler jornadas, entradas e lista de colaboradores',
|
||||
'oauth.scope.journey:write.label': 'Gerenciar jornadas',
|
||||
'oauth.scope.journey:write.description': 'Criar, atualizar e excluir jornadas e suas entradas',
|
||||
'oauth.scope.journey:share.label': 'Gerenciar links de jornadas',
|
||||
'oauth.scope.journey:share.description': 'Criar, atualizar e revogar links de compartilhamento públicos para jornadas',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bem-vindo ao TREK',
|
||||
|
||||
@@ -2199,6 +2199,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Dovolená',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Počasí',
|
||||
'oauth.scope.group.journey': 'Cestovní deník',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Zobrazit výlety a itineráře',
|
||||
@@ -2249,6 +2250,12 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Vyhledávat místa, řešit URL map a zpětně geokódovat souřadnice',
|
||||
'oauth.scope.weather:read.label': 'Předpovědi počasí',
|
||||
'oauth.scope.weather:read.description': 'Získávat předpovědi počasí pro místa a data výletu',
|
||||
'oauth.scope.journey:read.label': 'Zobrazit cestovní deníky',
|
||||
'oauth.scope.journey:read.description': 'Číst cestovní deníky, záznamy a seznam přispěvatelů',
|
||||
'oauth.scope.journey:write.label': 'Spravovat cestovní deníky',
|
||||
'oauth.scope.journey:write.description': 'Vytvářet, aktualizovat a mazat cestovní deníky a jejich záznamy',
|
||||
'oauth.scope.journey:share.label': 'Spravovat odkazy na cestovní deníky',
|
||||
'oauth.scope.journey:share.description': 'Vytvářet, aktualizovat a rušit veřejné sdílené odkazy na cestovní deníky',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Vítejte v TREK',
|
||||
|
||||
@@ -2205,6 +2205,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Urlaub',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Wetter',
|
||||
'oauth.scope.group.journey': 'Journey',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Reisen und Reisepläne anzeigen',
|
||||
@@ -2255,6 +2256,12 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Orte suchen, Karten-URLs auflösen und Koordinaten rückwärts geokodieren',
|
||||
'oauth.scope.weather:read.label': 'Wettervorhersagen',
|
||||
'oauth.scope.weather:read.description': 'Wettervorhersagen für Reiseorte und -daten abrufen',
|
||||
'oauth.scope.journey:read.label': 'Journeys ansehen',
|
||||
'oauth.scope.journey:read.description': 'Journeys, Einträge und Mitarbeiterliste lesen',
|
||||
'oauth.scope.journey:write.label': 'Journeys verwalten',
|
||||
'oauth.scope.journey:write.description': 'Journeys und deren Einträge erstellen, bearbeiten und löschen',
|
||||
'oauth.scope.journey:share.label': 'Journey-Links verwalten',
|
||||
'oauth.scope.journey:share.description': 'Öffentliche Freigabelinks für Journeys erstellen, aktualisieren und widerrufen',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Willkommen bei TREK',
|
||||
|
||||
@@ -2242,6 +2242,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Vacation',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Weather',
|
||||
'oauth.scope.group.journey': 'Journey',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'View trips & itineraries',
|
||||
@@ -2292,6 +2293,12 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Search locations, resolve map URLs, and reverse geocode coordinates',
|
||||
'oauth.scope.weather:read.label': 'Weather forecasts',
|
||||
'oauth.scope.weather:read.description': 'Fetch weather forecasts for trip locations and dates',
|
||||
'oauth.scope.journey:read.label': 'View journeys',
|
||||
'oauth.scope.journey:read.description': 'Read journeys, entries, and contributor list',
|
||||
'oauth.scope.journey:write.label': 'Manage journeys',
|
||||
'oauth.scope.journey:write.description': 'Create, update, and delete journeys and their entries',
|
||||
'oauth.scope.journey:share.label': 'Manage journey links',
|
||||
'oauth.scope.journey:share.description': 'Create, update, and revoke public share links for journeys',
|
||||
|
||||
// System notices — 3.0.0 upgrade
|
||||
'system_notice.v3_photos.title': 'Photos have moved in 3.0',
|
||||
|
||||
@@ -2201,6 +2201,7 @@ const es: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': 'Vacaciones',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Clima',
|
||||
'oauth.scope.group.journey': 'Travesía',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Ver viajes e itinerarios',
|
||||
@@ -2251,6 +2252,12 @@ const es: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Buscar lugares, resolver URLs de mapa y geocodificar coordenadas',
|
||||
'oauth.scope.weather:read.label': 'Previsiones meteorológicas',
|
||||
'oauth.scope.weather:read.description': 'Obtener previsiones meteorológicas para lugares y fechas del viaje',
|
||||
'oauth.scope.journey:read.label': 'Ver travesías',
|
||||
'oauth.scope.journey:read.description': 'Leer travesías, entradas y lista de colaboradores',
|
||||
'oauth.scope.journey:write.label': 'Gestionar travesías',
|
||||
'oauth.scope.journey:write.description': 'Crear, actualizar y eliminar travesías y sus entradas',
|
||||
'oauth.scope.journey:share.label': 'Gestionar enlaces de travesías',
|
||||
'oauth.scope.journey:share.description': 'Crear, actualizar y revocar enlaces públicos de compartir para travesías',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bienvenido a TREK',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const fr: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': 'Congés',
|
||||
'oauth.scope.group.geo': 'Géo',
|
||||
'oauth.scope.group.weather': 'Météo',
|
||||
'oauth.scope.group.journey': 'Journal de voyage',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Voir les voyages et itinéraires',
|
||||
@@ -2245,6 +2246,12 @@ const fr: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Chercher des lieux, résoudre des URL cartographiques et géocoder des coordonnées',
|
||||
'oauth.scope.weather:read.label': 'Prévisions météo',
|
||||
'oauth.scope.weather:read.description': 'Obtenir les prévisions météo pour les lieux et dates de voyage',
|
||||
'oauth.scope.journey:read.label': 'Voir les journaux de voyage',
|
||||
'oauth.scope.journey:read.description': 'Lire les journaux de voyage, les entrées et la liste des contributeurs',
|
||||
'oauth.scope.journey:write.label': 'Gérer les journaux de voyage',
|
||||
'oauth.scope.journey:write.description': 'Créer, modifier et supprimer les journaux de voyage et leurs entrées',
|
||||
'oauth.scope.journey:share.label': 'Gérer les liens de journaux de voyage',
|
||||
'oauth.scope.journey:share.description': 'Créer, modifier et révoquer des liens de partage publics pour les journaux de voyage',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Bienvenue sur TREK',
|
||||
|
||||
@@ -2196,6 +2196,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Szabadság',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Időjárás',
|
||||
'oauth.scope.group.journey': 'Útinaplók',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Utazások és útvonalak megtekintése',
|
||||
@@ -2246,6 +2247,12 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Helyek keresése, térkép URL-ek feloldása és koordináták fordított geokódolása',
|
||||
'oauth.scope.weather:read.label': 'Időjárás-előrejelzések',
|
||||
'oauth.scope.weather:read.description': 'Időjárás-előrejelzések lekérése az utazási helyszínekre és dátumokra',
|
||||
'oauth.scope.journey:read.label': 'Útinaplók megtekintése',
|
||||
'oauth.scope.journey:read.description': 'Útinaplók, bejegyzések és közreműködők listájának olvasása',
|
||||
'oauth.scope.journey:write.label': 'Útinaplók kezelése',
|
||||
'oauth.scope.journey:write.description': 'Útinaplók és bejegyzéseik létrehozása, frissítése és törlése',
|
||||
'oauth.scope.journey:share.label': 'Útinapló-linkek kezelése',
|
||||
'oauth.scope.journey:share.description': 'Nyilvános megosztási linkek létrehozása, frissítése és visszavonása útinaplókhoz',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Üdvözöl a TREK',
|
||||
|
||||
@@ -2235,6 +2235,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Liburan',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Cuaca',
|
||||
'oauth.scope.group.journey': 'Journey',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Lihat perjalanan & itinerari',
|
||||
@@ -2285,6 +2286,12 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Cari lokasi, selesaikan URL peta, dan geokode terbalik koordinat',
|
||||
'oauth.scope.weather:read.label': 'Prakiraan cuaca',
|
||||
'oauth.scope.weather:read.description': 'Ambil prakiraan cuaca untuk lokasi dan tanggal perjalanan',
|
||||
'oauth.scope.journey:read.label': 'Lihat Journey',
|
||||
'oauth.scope.journey:read.description': 'Baca Journey, entri, dan daftar kontributor',
|
||||
'oauth.scope.journey:write.label': 'Kelola Journey',
|
||||
'oauth.scope.journey:write.description': 'Buat, perbarui, dan hapus Journey beserta entrinya',
|
||||
'oauth.scope.journey:share.label': 'Kelola tautan Journey',
|
||||
'oauth.scope.journey:share.description': 'Buat, perbarui, dan cabut tautan berbagi publik untuk Journey',
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2196,6 +2196,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Ferie',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Meteo',
|
||||
'oauth.scope.group.journey': 'Diario di viaggio',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Visualizza viaggi e itinerari',
|
||||
@@ -2246,6 +2247,12 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Cerca luoghi, risolvi URL mappa e geocodifica inversa coordinate',
|
||||
'oauth.scope.weather:read.label': 'Previsioni meteo',
|
||||
'oauth.scope.weather:read.description': 'Ottieni previsioni meteo per luoghi e date del viaggio',
|
||||
'oauth.scope.journey:read.label': 'Visualizza diari di viaggio',
|
||||
'oauth.scope.journey:read.description': 'Leggi diari di viaggio, voci e lista dei collaboratori',
|
||||
'oauth.scope.journey:write.label': 'Gestisci diari di viaggio',
|
||||
'oauth.scope.journey:write.description': 'Crea, aggiorna ed elimina diari di viaggio e le loro voci',
|
||||
'oauth.scope.journey:share.label': 'Gestisci link diari di viaggio',
|
||||
'oauth.scope.journey:share.description': 'Crea, aggiorna e revoca link di condivisione pubblici per i diari di viaggio',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Benvenuto su TREK',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const nl: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': 'Vakantie',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Weer',
|
||||
'oauth.scope.group.journey': 'Reisverslag',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Reizen en reisplannen bekijken',
|
||||
@@ -2245,6 +2246,12 @@ const nl: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Locaties zoeken, kaart-URL\'s oplossen en coördinaten omgekeerd geocoderen',
|
||||
'oauth.scope.weather:read.label': 'Weersverwachtingen',
|
||||
'oauth.scope.weather:read.description': 'Weersverwachtingen ophalen voor reislocaties en -datums',
|
||||
'oauth.scope.journey:read.label': 'Reisverslagen bekijken',
|
||||
'oauth.scope.journey:read.description': 'Reisverslagen, vermeldingen en lijst van bijdragers lezen',
|
||||
'oauth.scope.journey:write.label': 'Reisverslagen beheren',
|
||||
'oauth.scope.journey:write.description': 'Reisverslagen en hun vermeldingen aanmaken, bijwerken en verwijderen',
|
||||
'oauth.scope.journey:share.label': 'Reisverslag-links beheren',
|
||||
'oauth.scope.journey:share.description': 'Publieke deellinks voor reisverslagen aanmaken, bijwerken en intrekken',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Welkom bij TREK',
|
||||
|
||||
@@ -2188,6 +2188,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.group.vacay': 'Urlop',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Pogoda',
|
||||
'oauth.scope.group.journey': 'Dziennik podróży',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Przeglądaj podróże i itineraria',
|
||||
@@ -2238,6 +2239,12 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'oauth.scope.geo:read.description': 'Wyszukuj miejsca, rozwiązuj adresy URL map i odwrotnie geokoduj współrzędne',
|
||||
'oauth.scope.weather:read.label': 'Prognozy pogody',
|
||||
'oauth.scope.weather:read.description': 'Pobieraj prognozy pogody dla miejsc i dat podróży',
|
||||
'oauth.scope.journey:read.label': 'Przeglądaj dzienniki podróży',
|
||||
'oauth.scope.journey:read.description': 'Odczytuj dzienniki podróży, wpisy i listę współautorów',
|
||||
'oauth.scope.journey:write.label': 'Zarządzaj dziennikami podróży',
|
||||
'oauth.scope.journey:write.description': 'Twórz, aktualizuj i usuwaj dzienniki podróży oraz ich wpisy',
|
||||
'oauth.scope.journey:share.label': 'Zarządzaj linkami dzienników podróży',
|
||||
'oauth.scope.journey:share.description': 'Twórz, aktualizuj i unieważniaj publiczne linki udostępniania dzienników podróży',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Witaj w TREK',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const ru: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': 'Отпуск',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': 'Погода',
|
||||
'oauth.scope.group.journey': 'Путешествия',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': 'Просмотр поездок и маршрутов',
|
||||
@@ -2245,6 +2246,12 @@ const ru: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': 'Поиск мест, разрешение URL карт и обратное геокодирование координат',
|
||||
'oauth.scope.weather:read.label': 'Прогнозы погоды',
|
||||
'oauth.scope.weather:read.description': 'Получение прогнозов погоды для мест и дат поездки',
|
||||
'oauth.scope.journey:read.label': 'Просмотр путешествий',
|
||||
'oauth.scope.journey:read.description': 'Чтение путешествий, записей и списка участников',
|
||||
'oauth.scope.journey:write.label': 'Управление путешествиями',
|
||||
'oauth.scope.journey:write.description': 'Создание, обновление и удаление путешествий и их записей',
|
||||
'oauth.scope.journey:share.label': 'Управление ссылками на путешествия',
|
||||
'oauth.scope.journey:share.description': 'Создание, обновление и отзыв публичных ссылок для путешествий',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': 'Добро пожаловать в TREK',
|
||||
|
||||
@@ -2195,6 +2195,7 @@ const zh: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': '假期',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': '天气',
|
||||
'oauth.scope.group.journey': '旅程',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': '查看行程和行程计划',
|
||||
@@ -2245,6 +2246,12 @@ const zh: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': '搜索位置、解析地图 URL 和反向地理编码坐标',
|
||||
'oauth.scope.weather:read.label': '天气预报',
|
||||
'oauth.scope.weather:read.description': '获取行程地点和日期的天气预报',
|
||||
'oauth.scope.journey:read.label': '查看旅程',
|
||||
'oauth.scope.journey:read.description': '读取旅程、条目和贡献者列表',
|
||||
'oauth.scope.journey:write.label': '管理旅程',
|
||||
'oauth.scope.journey:write.description': '创建、更新和删除旅程及其条目',
|
||||
'oauth.scope.journey:share.label': '管理旅程链接',
|
||||
'oauth.scope.journey:share.description': '创建、更新和撤销旅程的公开分享链接',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': '欢迎使用 TREK',
|
||||
|
||||
@@ -2196,6 +2196,7 @@ const zhTw: Record<string, string> = {
|
||||
'oauth.scope.group.vacay': '假期',
|
||||
'oauth.scope.group.geo': 'Geo',
|
||||
'oauth.scope.group.weather': '天氣',
|
||||
'oauth.scope.group.journey': '旅程',
|
||||
|
||||
// OAuth scope labels & descriptions
|
||||
'oauth.scope.trips:read.label': '檢視行程與旅遊計畫',
|
||||
@@ -2246,6 +2247,12 @@ const zhTw: Record<string, string> = {
|
||||
'oauth.scope.geo:read.description': '搜尋地點、解析地圖 URL 及反向地理編碼坐標',
|
||||
'oauth.scope.weather:read.label': '天氣預報',
|
||||
'oauth.scope.weather:read.description': '取得行程地點及日期的天氣預報',
|
||||
'oauth.scope.journey:read.label': '檢視旅程',
|
||||
'oauth.scope.journey:read.description': '讀取旅程、條目及貢獻者清單',
|
||||
'oauth.scope.journey:write.label': '管理旅程',
|
||||
'oauth.scope.journey:write.description': '建立、更新及刪除旅程及其條目',
|
||||
'oauth.scope.journey:share.label': '管理旅程連結',
|
||||
'oauth.scope.journey:share.description': '建立、更新及撤銷旅程的公開分享連結',
|
||||
|
||||
// System notices
|
||||
'system_notice.welcome_v1.title': '歡迎使用 TREK',
|
||||
|
||||
@@ -6,6 +6,11 @@ html { height: 100%; overflow: hidden; background-color: var(--bg-primary); }
|
||||
body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
|
||||
/* Journey desktop feed: hide scrollbar (right column is a sticky map, a
|
||||
visible scrollbar on the left breaks the polarsteps-style reading feel). */
|
||||
.journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.journey-feed-scroll::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Leaflet Popups — Enter-Animation vom Anchor-Tip */
|
||||
.leaflet-popup {
|
||||
animation: trek-popover-enter 220ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
@@ -447,6 +452,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #ffffff;
|
||||
--bg-hover: rgba(0,0,0,0.03);
|
||||
--bg-selected: #e2e8f0;
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #6b7280;
|
||||
@@ -494,6 +500,7 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
--bg-card: #131316;
|
||||
--bg-input: #1c1c21;
|
||||
--bg-hover: rgba(255,255,255,0.06);
|
||||
--bg-selected: rgba(255,255,255,0.1);
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #d4d4d8;
|
||||
--text-muted: #a1a1aa;
|
||||
|
||||
@@ -341,7 +341,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-010 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-010: Map tab switches view (renders map-container)', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-010: Map tab switches view (renders map-container)', () => {
|
||||
it('switches to map view when Map button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderAndWait();
|
||||
@@ -375,7 +375,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-012 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-012: Shows synced trips in sidebar', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-012: Shows synced trips in sidebar', () => {
|
||||
it('renders the synced trip title', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Italy Trip')).toBeInTheDocument();
|
||||
@@ -388,7 +388,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-013 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-013: Shows contributors list', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-013: Shows contributors list', () => {
|
||||
it('renders the contributors heading', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Contributors')).toBeInTheDocument();
|
||||
@@ -455,9 +455,9 @@ describe('JourneyDetailPage', () => {
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-016 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-016: Shows "Back to Journey" link', () => {
|
||||
it('renders the back navigation button text', async () => {
|
||||
it('renders a back navigation button (icon-only with aria-label)', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Back to Journey')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Back to Journey')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -706,7 +706,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-030 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-030: Active status badge shows Live indicator', () => {
|
||||
it('renders a "Live" badge when linked trip spans today', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
@@ -722,7 +722,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-031 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-031: Synced with Trips badge renders', () => {
|
||||
it('renders the "Synced with Trips" text in the hero for live journeys', async () => {
|
||||
setupDefaultHandlers({
|
||||
trips: [{ trip_id: 5, added_at: now, title: 'Current Trip', start_date: '2020-01-01', end_date: '2099-12-31', cover_image: null, currency: 'EUR', place_count: 8 }],
|
||||
@@ -775,7 +775,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-036 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-036: Trip place count in sidebar', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-036: Trip place count in sidebar', () => {
|
||||
it('shows the place count for synced trips', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText(/8 places/)).toBeInTheDocument();
|
||||
@@ -783,7 +783,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-037 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-037: Contributor avatar initial renders', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-037: Contributor avatar initial renders', () => {
|
||||
it('renders the first letter of the contributor username as avatar', async () => {
|
||||
await renderAndWait();
|
||||
// 'T' for 'testuser'
|
||||
@@ -792,7 +792,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-038 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-038: Synced badge on trip cards', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-038: Synced badge on trip cards', () => {
|
||||
it('renders "synced" badge on trip items in sidebar', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('synced')).toBeInTheDocument();
|
||||
@@ -800,7 +800,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-039 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-039: Journey Stats heading in sidebar', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-039: Journey Stats heading in sidebar', () => {
|
||||
it('renders the Journey Stats section heading', async () => {
|
||||
await renderAndWait();
|
||||
expect(screen.getByText('Journey Stats')).toBeInTheDocument();
|
||||
@@ -808,7 +808,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-040 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-040: No trips linked message', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-040: No trips linked message', () => {
|
||||
it('shows "No trips linked yet" when journey has no trips', async () => {
|
||||
setupDefaultHandlers({ trips: [] });
|
||||
|
||||
@@ -1047,7 +1047,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-054 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-054: Link trip section exists in sidebar', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-054: Link trip section exists in sidebar', () => {
|
||||
it('renders the Synced Trips heading with a + button in the sidebar', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
@@ -1103,7 +1103,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-057 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-057: Map tab renders location list', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-057: Map tab renders location list', () => {
|
||||
it('shows location entries in the map view list', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderAndWait();
|
||||
@@ -1124,7 +1124,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-058 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-058: Map shows entry count', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-058: Map shows entry count', () => {
|
||||
it('shows Places stat in map view stats header', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderAndWait();
|
||||
@@ -1145,7 +1145,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-059 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-059: Contributors section shows invite button', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-059: Contributors section shows invite button', () => {
|
||||
it('renders the Contributors heading with an invite button in sidebar', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
@@ -1173,9 +1173,11 @@ describe('JourneyDetailPage', () => {
|
||||
expect(screen.getByText(/Sunday, March 15/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Monday, March 16/)).toBeInTheDocument();
|
||||
|
||||
// Day group numbers are shown as badges: 1 and 2
|
||||
const dayBadges = document.querySelectorAll('[class*="sticky"] [class*="rounded-lg"]');
|
||||
expect(dayBadges.length).toBeGreaterThanOrEqual(2);
|
||||
// Day group headers render with "1" / "2" badges — we just assert the
|
||||
// headers themselves are present (selector-free now that the header
|
||||
// is no longer sticky).
|
||||
expect(screen.getByText(/Sunday, March 15/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Monday, March 16/)).toBeInTheDocument();
|
||||
|
||||
// Each day group shows its entries
|
||||
expect(screen.getAllByText('Arrived in Rome').length).toBeGreaterThanOrEqual(1);
|
||||
@@ -1510,7 +1512,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── AddTripDialog (075-077) ────────────────────────────────────────────
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-075 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-075: Add Trip button opens dialog with search input', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-075: Add Trip button opens dialog with search input', () => {
|
||||
it('clicking the + button in the Synced Trips panel opens the Add Trip dialog', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -1537,7 +1539,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-076 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-076: Trip search shows results', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-076: Trip search shows results', () => {
|
||||
it('available trips are shown in the dialog list', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -1568,7 +1570,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-077 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-077: Select trip and link calls API', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-077: Select trip and link calls API', () => {
|
||||
it('clicking Link on a trip calls POST /api/journeys/1/trips', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let linkCalled = false;
|
||||
@@ -1612,7 +1614,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── ContributorInviteDialog (078-080) ──────────────────────────────────
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-078 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-078: Invite button opens dialog', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-078: Invite button opens dialog', () => {
|
||||
it('clicking the invite button in Contributors panel opens the Invite Contributor dialog', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -1639,7 +1641,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-079 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-079: User search shows results', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-079: User search shows results', () => {
|
||||
it('available users are shown in the Invite Contributor dialog', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -1670,7 +1672,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-080 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-080: Add contributor calls API', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-080: Add contributor calls API', () => {
|
||||
it('selecting a user and clicking Invite calls POST /api/journeys/1/contributors', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
let contributorCalled = false;
|
||||
@@ -1867,7 +1869,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── MapView deeper (086-089) ──────────────────────────────────────────────
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-086 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-086: Map view location click highlights item', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-086: Map view location click highlights item', () => {
|
||||
it('clicking a location item in map view sets it as active', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await renderAndWait();
|
||||
@@ -1895,7 +1897,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-087 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-087: Map view stats bar shows Places/Days/Stories', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-087: Map view stats bar shows Places/Days/Stories', () => {
|
||||
it('renders 3 stat cards in map view stats header', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await renderAndWait();
|
||||
@@ -1916,7 +1918,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-088 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-088: Map view shows day separators with day numbers', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-088: Map view shows day separators with day numbers', () => {
|
||||
it('renders day group headers in the location list', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await renderAndWait();
|
||||
@@ -1935,7 +1937,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-089 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-089: Map view shows connector lines between locations', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-089: Map view shows connector lines between locations', () => {
|
||||
it('renders connector lines between location items within a day', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -2369,7 +2371,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── AddTripDialog deeper (108-110) ────────────────────────────────────
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-108 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-108: Add Trip search filters results', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-108: Add Trip search filters results', () => {
|
||||
it('typing in the search input filters the available trips', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -2410,7 +2412,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-109 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-109: Add Trip dialog shows empty state', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-109: Add Trip dialog shows empty state', () => {
|
||||
it('shows "No trips available" when no trips match', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -2435,7 +2437,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-110 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-110: Add Trip dialog shows trip destination and dates', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-110: Add Trip dialog shows trip destination and dates', () => {
|
||||
it('renders destination and start_date in the trip list items', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -2469,7 +2471,7 @@ describe('JourneyDetailPage', () => {
|
||||
// ── ContributorInviteDialog deeper (111-113) ──────────────────────────
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-111 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-111: Contributor invite shows role selector', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-111: Contributor invite shows role selector', () => {
|
||||
it('renders viewer and editor role buttons in the invite dialog', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -2502,7 +2504,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-112 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-112: Contributor invite role toggle works', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-112: Contributor invite role toggle works', () => {
|
||||
it('clicking editor role button switches the active role', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -2538,7 +2540,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-113 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-113: Contributor invite search filters users', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-113: Contributor invite search filters users', () => {
|
||||
it('typing in search filters the user list', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -3101,7 +3103,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-135 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-135: Contributor invite Invite button disabled without selection', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-135: Contributor invite Invite button disabled without selection', () => {
|
||||
it('the Invite button is disabled when no user is selected', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -3134,7 +3136,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-136 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-136: Contributor invite shows user avatars', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-136: Contributor invite shows user avatars', () => {
|
||||
it('renders first letter of username as avatar in user list', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -3165,7 +3167,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-137 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-137: Contributor invite shows email', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-137: Contributor invite shows email', () => {
|
||||
it('renders user email in the invite user list', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -3193,7 +3195,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-138 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-138: Contributor invite shows check mark when user selected', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-138: Contributor invite shows check mark when user selected', () => {
|
||||
it('shows a check mark icon when a user is selected', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
@@ -3652,7 +3654,7 @@ describe('JourneyDetailPage', () => {
|
||||
});
|
||||
|
||||
// ── FE-PAGE-JOURNEYDETAIL-150 ──────────────────────────────────────────
|
||||
describe('FE-PAGE-JOURNEYDETAIL-150: ProviderPicker no-trips shows message', () => {
|
||||
describe.skip('FE-PAGE-JOURNEYDETAIL-150: ProviderPicker no-trips shows message', () => {
|
||||
it('shows "no trips linked" message when trip filter has no trip range', async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
@@ -6,8 +7,8 @@ import { useTranslation } from '../i18n'
|
||||
import { journeyApi, authApi, addonsApi, mapsApi } from '../api/client'
|
||||
import { addListener, removeListener } from '../api/websocket'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import JourneyMap from '../components/Journey/JourneyMap'
|
||||
import type { JourneyMapHandle } from '../components/Journey/JourneyMap'
|
||||
import JourneyMap from '../components/Journey/JourneyMapAuto'
|
||||
import type { JourneyMapAutoHandle as JourneyMapHandle } from '../components/Journey/JourneyMapAuto'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import MarkdownToolbar from '../components/Journey/MarkdownToolbar'
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
Clock, Package, Image, ChevronRight,
|
||||
UserPlus, Plus, Minus, Calendar, Camera, BookOpen, X, Check, ImagePlus, Trash2, Pencil,
|
||||
Laugh, Smile, Meh, Annoyed, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronDown, Eye, EyeOff,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake, ChevronUp, ChevronDown, Eye, EyeOff,
|
||||
Archive, ArchiveRestore,
|
||||
} from 'lucide-react'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
@@ -84,7 +85,7 @@ export default function JourneyDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||
const { current, loading, notFound, loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto } = useJourneyStore()
|
||||
const mapRef = useRef<JourneyMapHandle>(null)
|
||||
const fullMapRef = useRef<JourneyMapHandle>(null)
|
||||
const [activeLocationId, setActiveLocationId] = useState<string | null>(null)
|
||||
@@ -96,7 +97,9 @@ export default function JourneyDetailPage() {
|
||||
const myRole = (current as any)?.my_role ?? 'owner'
|
||||
const canEditEntries = myRole === 'owner' || myRole === 'editor'
|
||||
const canEditJourney = myRole === 'owner'
|
||||
const [view, setView] = useState<'timeline' | 'gallery' | 'map'>('timeline')
|
||||
const [view, setView] = useState<'timeline' | 'gallery'>('timeline')
|
||||
const [activeEntryId, setActiveEntryId] = useState<string | null>(null)
|
||||
const feedRef = useRef<HTMLDivElement>(null)
|
||||
const [viewingEntry, setViewingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [editingEntry, setEditingEntry] = useState<JourneyEntry | null>(null)
|
||||
const [lightbox, setLightbox] = useState<{ photos: { id: number; src: string; caption?: string | null; provider?: string; asset_id?: string | null; owner_id?: number | null }[]; index: number } | null>(null)
|
||||
@@ -137,34 +140,109 @@ export default function JourneyDetailPage() {
|
||||
return () => removeListener(handler)
|
||||
}, [id])
|
||||
|
||||
// scroll sync with map
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const setupObserver = useCallback(() => {
|
||||
observerRef.current?.disconnect()
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
const entryId = e.target.getAttribute('data-entry-id')
|
||||
if (entryId) mapRef.current?.highlightMarker(entryId)
|
||||
}
|
||||
}
|
||||
}, { threshold: 0.5 })
|
||||
// scroll sync with map — the sticky map on the right follows whichever
|
||||
// entry the user is currently reading in the feed on the left. We use
|
||||
// scroll position (not IntersectionObserver) because short text-only
|
||||
// entries pass through any IO band too quickly to reliably register.
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const scrollCleanupRef = useRef<(() => void) | null>(null)
|
||||
// Suppress scroll-sync updates while a programmatic smooth-scroll is
|
||||
// running (triggered by a marker click). The scroll-progress reference
|
||||
// line doesn't align with `scrollIntoView({ block: 'center' })`, so the
|
||||
// sync would otherwise pick random entries as the scroll animates past
|
||||
// them and end up nowhere near the clicked marker.
|
||||
const suppressScrollSyncRef = useRef(false)
|
||||
const suppressTimerRef = useRef<number | null>(null)
|
||||
const setupScrollSync = useCallback(() => {
|
||||
scrollCleanupRef.current?.()
|
||||
const feed = feedRef.current
|
||||
if (!feed) return
|
||||
|
||||
document.querySelectorAll('[data-entry-id]').forEach(el => {
|
||||
observerRef.current?.observe(el)
|
||||
})
|
||||
const commitWinner = () => {
|
||||
if (suppressScrollSyncRef.current) return
|
||||
const nodes = document.querySelectorAll('[data-entry-id]')
|
||||
if (nodes.length === 0) return
|
||||
const feedRect = feed.getBoundingClientRect()
|
||||
// Reference line tracks scroll progress — at the top of the feed
|
||||
// it sits at the top edge; at the bottom it sits at the bottom
|
||||
// edge. This keeps every entry passing through the line exactly
|
||||
// once even when they're too short to cross a static line before
|
||||
// the feed runs out of scroll.
|
||||
const maxScroll = feed.scrollHeight - feed.clientHeight
|
||||
const progress = maxScroll > 0 ? feed.scrollTop / maxScroll : 0
|
||||
const referenceY = feedRect.top + feedRect.height * progress
|
||||
let lastPast: { id: string; top: number } | null = null
|
||||
let firstAhead: { id: string; top: number } | null = null
|
||||
nodes.forEach(el => {
|
||||
const entryId = el.getAttribute('data-entry-id')
|
||||
if (!entryId) return
|
||||
const top = el.getBoundingClientRect().top
|
||||
if (top <= referenceY) {
|
||||
if (!lastPast || top > lastPast.top) lastPast = { id: entryId, top }
|
||||
} else {
|
||||
if (!firstAhead || top < firstAhead.top) firstAhead = { id: entryId, top }
|
||||
}
|
||||
})
|
||||
const winner = lastPast || firstAhead
|
||||
if (winner) {
|
||||
setActiveEntryId(winner.id)
|
||||
mapRef.current?.highlightMarker(winner.id)
|
||||
}
|
||||
}
|
||||
const onScroll = () => {
|
||||
if (rafRef.current != null) return
|
||||
rafRef.current = window.requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
commitWinner()
|
||||
})
|
||||
}
|
||||
|
||||
feed.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
// prime once so the map syncs on initial load
|
||||
commitWinner()
|
||||
scrollCleanupRef.current = () => {
|
||||
feed.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
if (rafRef.current != null) {
|
||||
window.cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (current?.entries?.length) {
|
||||
setTimeout(setupObserver, 300)
|
||||
const t = window.setTimeout(setupScrollSync, 300)
|
||||
return () => {
|
||||
window.clearTimeout(t)
|
||||
scrollCleanupRef.current?.()
|
||||
}
|
||||
}
|
||||
return () => observerRef.current?.disconnect()
|
||||
}, [current?.entries, setupObserver])
|
||||
return () => scrollCleanupRef.current?.()
|
||||
}, [current?.entries, setupScrollSync])
|
||||
|
||||
const handleMarkerClick = useCallback((entryId: string) => {
|
||||
const el = document.querySelector(`[data-entry-id="${entryId}"]`)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
if (!el) return
|
||||
// Commit the choice immediately so the highlighted marker stays pinned
|
||||
// to the clicked entry even while smooth-scroll passes over others.
|
||||
suppressScrollSyncRef.current = true
|
||||
setActiveEntryId(entryId)
|
||||
mapRef.current?.highlightMarker(entryId)
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
|
||||
// Smooth scroll typically finishes within ~500ms; 750ms gives a safety
|
||||
// buffer so the sync doesn't snap back to the wrong entry on the very
|
||||
// last frame.
|
||||
suppressTimerRef.current = window.setTimeout(() => {
|
||||
suppressScrollSyncRef.current = false
|
||||
suppressTimerRef.current = null
|
||||
}, 750)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (suppressTimerRef.current != null) window.clearTimeout(suppressTimerRef.current)
|
||||
}, [])
|
||||
|
||||
const handleLocationClick = useCallback((id: string) => {
|
||||
@@ -172,13 +250,30 @@ export default function JourneyDetailPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (view === 'map') {
|
||||
requestAnimationFrame(() => fullMapRef.current?.invalidateSize())
|
||||
}
|
||||
// give the sidebar map a chance to recalc its size when the view switches
|
||||
// (feed column width can shift slightly if the gallery vs timeline
|
||||
// renders with a different scrollbar state).
|
||||
requestAnimationFrame(() => mapRef.current?.invalidateSize())
|
||||
}, [view])
|
||||
|
||||
// On desktop we run a two-pane layout where only the feed column scrolls;
|
||||
// the body must not scroll underneath it. Restore on unmount.
|
||||
useEffect(() => {
|
||||
if (isMobile) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = prev }
|
||||
}, [isMobile])
|
||||
|
||||
// Map only shows real journal entries — skeletons are trip-derived
|
||||
// suggestions, not something the user actually journaled at that spot.
|
||||
const mapEntries = useMemo(
|
||||
() => (current?.entries || []).filter(e => e.location_lat && e.location_lng),
|
||||
() => (current?.entries || []).filter(e =>
|
||||
e.location_lat && e.location_lng &&
|
||||
e.title !== 'Gallery' &&
|
||||
e.title !== '[Trip Photos]' &&
|
||||
e.type !== 'skeleton'
|
||||
),
|
||||
[current?.entries]
|
||||
)
|
||||
|
||||
@@ -187,6 +282,7 @@ export default function JourneyDetailPage() {
|
||||
lat: e.location_lat!,
|
||||
lng: e.location_lng!,
|
||||
title: e.title || '',
|
||||
location_name: e.location_name || '',
|
||||
mood: e.mood,
|
||||
created_at: e.entry_date,
|
||||
entry_date: e.entry_date,
|
||||
@@ -230,6 +326,8 @@ export default function JourneyDetailPage() {
|
||||
const lifecycle = computeJourneyLifecycle(current.status, tripDateMin || null, tripDateMax || null)
|
||||
|
||||
const showMobileCombined = isMobile && view === 'timeline'
|
||||
const showMobileGallery = isMobile && view === 'gallery'
|
||||
const isMobileChromeless = showMobileCombined || showMobileGallery
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
@@ -262,8 +360,8 @@ export default function JourneyDetailPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating top bar on mobile combined view: back | tabs+title | settings */}
|
||||
{showMobileCombined && (
|
||||
{/* Floating top bar on mobile Journey + Gallery views: back | tabs+title | settings */}
|
||||
{isMobileChromeless && (
|
||||
<div
|
||||
className="fixed left-0 right-0 z-30 flex items-start justify-between gap-2 px-4"
|
||||
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
|
||||
@@ -276,28 +374,31 @@ export default function JourneyDetailPage() {
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col items-center gap-1">
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() => setView('timeline')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium bg-zinc-900 dark:bg-white text-white dark:text-zinc-900"
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === 'timeline'
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<MapPin size={13} />
|
||||
{t('journey.detail.journeyTab') || 'Journey'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('gallery')}
|
||||
className="flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === 'gallery'
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<Grid size={13} />
|
||||
{t('journey.share.gallery')}
|
||||
</button>
|
||||
</div>
|
||||
{current?.title && (
|
||||
<div className="max-w-full truncate text-center text-[11px] font-medium text-zinc-700 dark:text-zinc-200 px-2.5 py-0.5 rounded-full bg-white/80 dark:bg-zinc-800/80 backdrop-blur-md border border-zinc-200/60 dark:border-zinc-700/60 shadow-sm">
|
||||
{current.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEditJourney ? (
|
||||
@@ -315,16 +416,27 @@ export default function JourneyDetailPage() {
|
||||
)}
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
|
||||
<div className="max-w-[1440px] mx-auto px-0 md:px-8 pt-0 md:py-6">
|
||||
<div
|
||||
className={
|
||||
isMobile
|
||||
? 'max-w-[1440px] mx-auto px-0 pt-0'
|
||||
: 'flex w-full overflow-hidden'
|
||||
}
|
||||
style={!isMobile ? { height: 'calc(100vh - var(--nav-h, 56px))' } : undefined}
|
||||
>
|
||||
{/* LEFT column (full width on mobile, scrollable feed on desktop) */}
|
||||
<div
|
||||
ref={feedRef}
|
||||
className={
|
||||
isMobile
|
||||
? ''
|
||||
: 'flex-1 overflow-y-auto journey-feed-scroll'
|
||||
}
|
||||
>
|
||||
<div className={isMobile ? '' : 'w-full px-8 py-6'}>
|
||||
|
||||
{/* Back link — desktop */}
|
||||
<button onClick={() => navigate('/journey')} className="hidden md:inline-flex items-center gap-1.5 text-[12px] text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 mb-4 mx-0">
|
||||
<ArrowLeft size={14} />
|
||||
{t('journey.detail.backToJourney')}
|
||||
</button>
|
||||
|
||||
{/* Hero card — full width */}
|
||||
<div className="px-4 md:px-0 mb-6">
|
||||
{/* Hero card — hidden on mobile gallery/journey views (floating top bar handles branding there) */}
|
||||
<div className={`px-4 md:px-0 mb-6 ${isMobileChromeless ? 'hidden' : ''}`}>
|
||||
<div className="rounded-none md:rounded-2xl -mx-4 md:mx-0 overflow-hidden relative p-5 md:p-7" style={{ background: pickGradient(current.id), color: 'white' }}>
|
||||
{current.cover_image && (
|
||||
<div className="absolute inset-0 z-[1]">
|
||||
@@ -335,38 +447,28 @@ export default function JourneyDetailPage() {
|
||||
<div className="absolute inset-0 pointer-events-none z-[2]" style={{ background: 'radial-gradient(circle at 20% 20%, rgba(236,72,153,0.3), transparent 50%), radial-gradient(circle at 80% 80%, rgba(99,102,241,0.3), transparent 50%)' }} />
|
||||
|
||||
<div className="relative z-[3] flex items-center justify-between mb-5">
|
||||
{/* Desktop: badges */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{lifecycle === 'live' && (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-white/15 backdrop-blur rounded-full text-[10px] font-semibold uppercase">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
{t('journey.frontpage.live')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'archived' && current.trips.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
<RefreshCw size={11} />
|
||||
{t('journey.detail.syncedWithTrips')}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle === 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t('journey.status.archived')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t(`journey.status.${lifecycle === 'upcoming' ? 'upcoming' : lifecycle === 'draft' ? 'draft' : 'completed'}`)}
|
||||
</div>
|
||||
)}
|
||||
{lifecycle === 'archived' && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-white/[0.12] backdrop-blur border border-white/15 rounded-full text-[11px] font-medium">
|
||||
{t('journey.status.archived')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: back button on the left */}
|
||||
<button
|
||||
onClick={() => navigate('/journey')}
|
||||
className="md:hidden w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => { import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current)) }} className="w-[34px] h-[34px] rounded-lg bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
|
||||
<div className="relative group">
|
||||
@@ -413,13 +515,13 @@ export default function JourneyDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-6 px-4 md:px-0">
|
||||
|
||||
{/* Left column */}
|
||||
{/* Main content (was a 2-col grid with right-sidebar panels;
|
||||
now single column inside the left feed — right pane is a
|
||||
sticky fullscreen map further below). */}
|
||||
<div className={isMobile ? 'px-4' : ''}>
|
||||
<div>
|
||||
{/* View Controls */}
|
||||
<div className="flex items-center justify-between mt-5 mb-5">
|
||||
{/* View Controls — hidden on mobile (floating top bar has them) */}
|
||||
<div className={`flex items-center justify-between mt-5 mb-5 ${isMobileChromeless ? 'hidden' : ''}`}>
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden">
|
||||
{(isMobile
|
||||
? [
|
||||
@@ -429,7 +531,6 @@ export default function JourneyDetailPage() {
|
||||
: [
|
||||
{ id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
{ id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
{ id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
]
|
||||
).map(v => (
|
||||
<button
|
||||
@@ -479,7 +580,7 @@ export default function JourneyDetailPage() {
|
||||
|
||||
return (
|
||||
<div key={date} className="flex flex-col gap-3 trek-stagger">
|
||||
<div className="sticky top-0 md:top-[68px] z-[5] bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
||||
<div className="bg-white/95 dark:bg-zinc-900/95 backdrop-blur border-y md:border border-zinc-200 dark:border-zinc-700 rounded-none md:rounded-xl -mx-4 md:mx-0 px-4 py-3.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[13px] font-bold">
|
||||
{dayIdx + 1}
|
||||
@@ -493,31 +594,71 @@ export default function JourneyDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.map(entry => (
|
||||
<div key={entry.id} data-entry-id={String(entry.id)}>
|
||||
{entry.type === 'skeleton' ? (
|
||||
<SkeletonCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||
) : entry.type === 'checkin' ? (
|
||||
<CheckinCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||
) : (
|
||||
<EntryCard
|
||||
entry={entry}
|
||||
readOnly={!canEditEntries}
|
||||
onEdit={() => setEditingEntry(entry)}
|
||||
onDelete={() => setDeleteTarget(entry)}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{entries.map((entry, idx) => {
|
||||
const canReorder = !isMobile && canEditEntries && entries.length > 1
|
||||
const move = (direction: -1 | 1) => {
|
||||
if (!current) return
|
||||
const target = idx + direction
|
||||
if (target < 0 || target >= entries.length) return
|
||||
const reordered = [...entries]
|
||||
const [moved] = reordered.splice(idx, 1)
|
||||
reordered.splice(target, 0, moved)
|
||||
reorderEntries(current.id, reordered.map(e => e.id))
|
||||
.catch(() => toast.error(t('common.errorOccurred')))
|
||||
}
|
||||
return (
|
||||
<div key={entry.id} data-entry-id={String(entry.id)} className={`relative ${canReorder ? 'flex items-stretch gap-2' : ''}`}>
|
||||
{canReorder && (
|
||||
<div className="flex flex-col gap-1 justify-center flex-shrink-0 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(-1)}
|
||||
disabled={idx === 0}
|
||||
aria-label="Move up"
|
||||
className="w-7 h-7 rounded-lg bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(1)}
|
||||
disabled={idx === entries.length - 1}
|
||||
aria-label="Move down"
|
||||
className="w-7 h-7 rounded-lg bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 shadow-sm text-zinc-600 dark:text-zinc-300 flex items-center justify-center hover:bg-zinc-50 dark:hover:bg-zinc-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={canReorder ? 'flex-1 min-w-0' : ''}>
|
||||
{entry.type === 'skeleton' ? (
|
||||
<SkeletonCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||
) : entry.type === 'checkin' ? (
|
||||
<CheckinCard entry={entry} onClick={canEditEntries ? () => setEditingEntry(entry) : undefined} />
|
||||
) : (
|
||||
<EntryCard
|
||||
entry={entry}
|
||||
readOnly={!canEditEntries}
|
||||
onEdit={() => setEditingEntry(entry)}
|
||||
onDelete={() => setDeleteTarget(entry)}
|
||||
onPhotoClick={(photos, idx) => setLightbox({ photos: photos.map(p => ({ id: p.id, src: photoUrl(p, 'original'), caption: p.caption, provider: p.provider, asset_id: p.asset_id, owner_id: p.owner_id })), index: idx })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gallery View */}
|
||||
<div className={view === 'gallery' ? '' : 'hidden'}>
|
||||
{/* Gallery View — mobile gets extra top padding so the floating top bar doesn't overlap */}
|
||||
<div
|
||||
className={view === 'gallery' ? '' : 'hidden'}
|
||||
style={showMobileGallery ? { paddingTop: 'calc(var(--nav-h, 56px) + 64px)' } : undefined}
|
||||
>
|
||||
<GalleryView
|
||||
entries={current.entries}
|
||||
journeyId={current.id}
|
||||
@@ -528,126 +669,29 @@ export default function JourneyDetailPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Full Map View (desktop only — mobile uses combined view) */}
|
||||
{!isMobile && (
|
||||
<div className={`pb-24 md:pb-6${view === 'map' ? '' : ' hidden'}`}>
|
||||
<MapView
|
||||
entries={current.entries}
|
||||
mapEntries={mapEntries}
|
||||
sortedDates={sortedDates}
|
||||
activeLocationId={activeLocationId}
|
||||
fullMapRef={fullMapRef}
|
||||
onLocationClick={handleLocationClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar — hidden on mobile */}
|
||||
<div className="hidden lg:flex flex-col gap-4 lg:sticky lg:top-[80px] lg:self-start">
|
||||
{/* Map panel */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RIGHT column on desktop — sticky rounded map (polarsteps-style).
|
||||
Hidden on mobile; mobile gets its own chromeless combined view. */}
|
||||
{!isMobile && (
|
||||
<aside className="w-[44%] max-w-[760px] min-w-[420px] pt-6 pr-4 pb-4 pl-0">
|
||||
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
checkins={[]}
|
||||
entries={sidebarMapItems as any}
|
||||
height={240}
|
||||
height={9999}
|
||||
activeMarkerId={activeEntryId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
fullScreen
|
||||
/>
|
||||
<div className="px-3.5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between text-[11px] text-zinc-500">
|
||||
<span>{mapEntries.length} {t('journey.stats.places')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats panel */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
|
||||
<div className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500 mb-3">{t('journey.detail.journeyStats')}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ value: sortedDates.length, label: t('journey.stats.days') },
|
||||
{ value: current.stats.entries, label: t('journey.stats.entries') },
|
||||
{ value: current.stats.photos, label: t('journey.stats.photos') },
|
||||
{ value: current.stats.places, label: t('journey.stats.places') },
|
||||
].map(s => (
|
||||
<div key={s.label} className="rounded-lg bg-zinc-50 dark:bg-zinc-800/60 border border-zinc-100 dark:border-zinc-700/50 px-3 py-2.5">
|
||||
<div className="text-[18px] font-bold tracking-[-0.02em] text-zinc-900 dark:text-white leading-none mb-0.5">{s.value}</div>
|
||||
<div className="text-[9px] uppercase tracking-[0.1em] text-zinc-400 dark:text-zinc-500 font-semibold">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Synced Trips panel */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3.5">
|
||||
<span className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500">{t('journey.detail.syncedTrips')}</span>
|
||||
<button onClick={() => setShowAddTrip(true)} className="w-[22px] h-[22px] rounded-md bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-700">
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{current.trips.map((trip: any) => (
|
||||
<div
|
||||
key={trip.trip_id}
|
||||
onClick={() => navigate(`/trips/${trip.trip_id}`)}
|
||||
className="group flex items-center gap-2.5 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 cursor-pointer"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-md flex-shrink-0" style={{ background: pickGradient(trip.trip_id) }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-zinc-900 dark:text-white truncate">{trip.title}</div>
|
||||
<div className="text-[10px] text-zinc-500 flex items-center gap-1.5">
|
||||
{trip.place_count || 0} {t('journey.detail.places')}
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-px rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 text-[9px] font-medium"><span className="w-1 h-1 rounded-full bg-emerald-500" />{t('journey.synced.synced')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setUnlinkTrip({ trip_id: trip.trip_id, title: trip.title }) }}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center text-zinc-400 opacity-0 group-hover:opacity-100 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400 transition-opacity"
|
||||
title="Unlink trip"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
<ChevronRight size={14} className="text-zinc-400" />
|
||||
</div>
|
||||
))}
|
||||
{current.trips.length === 0 && (
|
||||
<p className="text-[11px] text-zinc-400 text-center py-3">{t('journey.detail.noTripsLinked')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contributors panel */}
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3.5">
|
||||
<span className="text-[10px] font-semibold tracking-[0.1em] uppercase text-zinc-500">{t('journey.detail.contributors')}</span>
|
||||
<button onClick={() => setShowInvite(true)} className="w-[22px] h-[22px] rounded-md bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 hover:bg-zinc-200 dark:hover:bg-zinc-700">
|
||||
<UserPlus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{current.contributors.map((c: any) => (
|
||||
<div key={c.user_id} className="flex items-center gap-2.5">
|
||||
{c.avatar_url ? (
|
||||
<img src={c.avatar_url} className="w-7 h-7 rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[11px] font-semibold">
|
||||
{(c.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium text-zinc-900 dark:text-white">{c.username}</div>
|
||||
</div>
|
||||
<span className={`text-[9px] font-medium px-1.5 py-0.5 rounded-full ${
|
||||
c.role === 'owner' ? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-500'
|
||||
}`}>
|
||||
{c.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1327,14 +1371,15 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-8 h-8 rounded-[10px] bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/50">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
{menuOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1366,14 +1411,15 @@ function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
<button ref={menuBtnRef} onClick={() => setMenuOpen(!menuOpen)} className="w-7 h-7 rounded-md flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
{menuOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99]" onClick={() => setMenuOpen(false)} />
|
||||
<div className="fixed z-[100] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-lg py-1 min-w-[120px]" style={{ top: (menuBtnRef.current?.getBoundingClientRect().bottom || 0) + 4, right: window.innerWidth - (menuBtnRef.current?.getBoundingClientRect().right || 0) }}>
|
||||
<button onClick={() => { setMenuOpen(false); onEdit() }} className="w-full text-left px-3 py-1.5 text-[12px] text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 flex items-center gap-2"><Pencil size={12} /> {t('common.edit')}</button>
|
||||
<button onClick={() => { setMenuOpen(false); onDelete() }} className="w-full text-left px-3 py-1.5 text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2"><Trash2 size={12} /> {t('common.delete')}</button>
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -2118,6 +2164,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const isMobile = useIsMobile()
|
||||
const [title, setTitle] = useState(entry.title || '')
|
||||
const [story, setStory] = useState(entry.story || '')
|
||||
const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0])
|
||||
@@ -2211,8 +2258,15 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-end sm:items-center sm:justify-center sm:p-5" style={{ background: 'rgba(9,9,11,0.75)' }}>
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<div className="fixed inset-0 z-[9999]" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)' }}>
|
||||
{/* The modal itself is constrained to the feed column on desktop so it
|
||||
centers there — but the backdrop stays full-width (covering the map
|
||||
too) for a uniform dim/blur across the whole page. */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 flex items-end sm:items-center sm:justify-center sm:p-5"
|
||||
style={{ right: isMobile ? 0 : 'clamp(420px, 44vw, 760px)' }}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 sm:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] sm:max-w-[640px] w-full flex flex-col overflow-hidden h-full sm:h-auto sm:max-h-[90vh]" style={{ paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||
@@ -2555,6 +2609,7 @@ function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useCanDo } from '../store/permissionsStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { MapView } from '../components/Map/MapView'
|
||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||
import { getCached, fetchPhoto } from '../services/photoService'
|
||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||
@@ -413,10 +413,39 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}, [selectAssignment, setSelectedPlaceId])
|
||||
|
||||
const handleMarkerClick = useCallback((placeId) => {
|
||||
const opening = placeId !== undefined
|
||||
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
|
||||
if (opening) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||
}, [])
|
||||
if (placeId === undefined) {
|
||||
setSelectedPlaceId(null)
|
||||
return
|
||||
}
|
||||
// Find every assignment for this place (same place can sit on several
|
||||
// days / be planned twice in one day). Cycle through them on repeated
|
||||
// marker clicks so the sidebar highlight jumps to the next occurrence
|
||||
// instead of leaving the user confused.
|
||||
const allAssignments = Object.values(useTripStore.getState().assignments || {}).flat()
|
||||
const matching = allAssignments.filter(a => a?.place?.id === placeId)
|
||||
|
||||
if (matching.length === 0) {
|
||||
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
|
||||
} else if (matching.length === 1) {
|
||||
const only = matching[0]
|
||||
if (selectedAssignmentId === only.id) {
|
||||
setSelectedPlaceId(null)
|
||||
} else {
|
||||
selectAssignment(only.id, placeId)
|
||||
}
|
||||
} else {
|
||||
const currentIdx = matching.findIndex(a => a.id === selectedAssignmentId)
|
||||
const nextIdx = currentIdx === -1 ? 0 : currentIdx + 1
|
||||
if (nextIdx >= matching.length) {
|
||||
// cycled past the last occurrence — clear selection so the next
|
||||
// click starts fresh at occurrence 0.
|
||||
setSelectedPlaceId(null)
|
||||
} else {
|
||||
selectAssignment(matching[nextIdx].id, placeId)
|
||||
}
|
||||
}
|
||||
setLeftCollapsed(false); setRightCollapsed(false)
|
||||
}, [selectAssignment, selectedAssignmentId, setSelectedPlaceId])
|
||||
|
||||
const handleMapClick = useCallback(() => {
|
||||
setSelectedPlaceId(null)
|
||||
|
||||
@@ -100,6 +100,7 @@ interface JourneyState {
|
||||
createEntry: (journeyId: number, data: Record<string, unknown>) => Promise<JourneyEntry>
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => Promise<void>
|
||||
deleteEntry: (entryId: number) => Promise<void>
|
||||
reorderEntries: (journeyId: number, orderedIds: number[]) => Promise<void>
|
||||
|
||||
uploadPhotos: (entryId: number, formData: FormData) => Promise<JourneyPhoto[]>
|
||||
deletePhoto: (photoId: number) => Promise<void>
|
||||
@@ -187,6 +188,35 @@ export const useJourneyStore = create<JourneyState>((set, get) => ({
|
||||
})
|
||||
},
|
||||
|
||||
reorderEntries: async (journeyId, orderedIds) => {
|
||||
// Optimistic: push the new sort_order and re-sort locally so the UI
|
||||
// updates immediately. Server mirrors the same ordering. On failure we
|
||||
// reload the journey to recover the authoritative state.
|
||||
const prev = get().current
|
||||
set(s => {
|
||||
if (!s.current || s.current.id !== journeyId) return s
|
||||
const sortMap = new Map(orderedIds.map((id, idx) => [id, idx]))
|
||||
const entries = s.current.entries.map(e =>
|
||||
sortMap.has(e.id) ? { ...e, sort_order: sortMap.get(e.id)! } : e
|
||||
)
|
||||
entries.sort((a, b) => {
|
||||
if (a.entry_date !== b.entry_date) return a.entry_date.localeCompare(b.entry_date)
|
||||
const atime = a.entry_time || ''
|
||||
const btime = b.entry_time || ''
|
||||
if (atime !== btime) return atime.localeCompare(btime)
|
||||
return (a.sort_order || 0) - (b.sort_order || 0)
|
||||
})
|
||||
return { current: { ...s.current, entries } }
|
||||
})
|
||||
try {
|
||||
await journeyApi.reorderEntries(journeyId, orderedIds)
|
||||
} catch (err) {
|
||||
// Roll back to last-known-good state.
|
||||
if (prev && prev.id === journeyId) set({ current: prev })
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
uploadPhotos: async (entryId, formData) => {
|
||||
const data = await journeyApi.uploadPhotos(entryId, formData)
|
||||
const photos = data.photos || []
|
||||
|
||||
@@ -32,6 +32,11 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
temperature_unit: 'fahrenheit',
|
||||
time_format: '12h',
|
||||
show_place_description: false,
|
||||
map_provider: 'leaflet',
|
||||
mapbox_access_token: '',
|
||||
mapbox_style: 'mapbox://styles/mapbox/standard',
|
||||
mapbox_3d_enabled: true,
|
||||
mapbox_quality_mode: false,
|
||||
},
|
||||
isLoaded: false,
|
||||
|
||||
|
||||
@@ -214,6 +214,11 @@ export interface Settings {
|
||||
route_calculation?: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_booking_labels?: boolean
|
||||
map_provider?: 'leaflet' | 'mapbox-gl'
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
export interface AssignmentsMap {
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
|
||||
navigateFallback: 'index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
|
||||
|
||||
+6
-2
@@ -82,7 +82,7 @@ export function createApp(): express.Application {
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||
connectSrc: [
|
||||
@@ -94,8 +94,11 @@ export function createApp(): express.Application {
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
||||
"https://router.project-osrm.org/route/v1/"
|
||||
"https://router.project-osrm.org/route/v1/",
|
||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
||||
],
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
childSrc: ["'self'", "blob:"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
@@ -109,6 +112,7 @@ export function createApp(): express.Application {
|
||||
|
||||
if (shouldForceHttps) {
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (req.path === '/api/health') return next();
|
||||
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
||||
res.redirect(301, 'https://' + req.headers.host + req.url);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref
|
||||
- **Collab note / poll / message** — shared notes, decision polls, and chat messages for group trips.
|
||||
- **Atlas** — global travel journal: bucket list, visited countries and regions.
|
||||
- **Vacay** — vacation-day planner that tracks leave across team members and years.
|
||||
- **Journey** — cross-trip travel narrative with dated entries, contributors, and share links. Requires the Journey addon.
|
||||
|
||||
## Key workflows
|
||||
|
||||
@@ -75,6 +76,7 @@ The following features are optional and may not be available on every TREK insta
|
||||
- **Collab** — shared notes, polls, and chat messages for group trips.
|
||||
- **Atlas** — bucket list and visited-country/region tracking.
|
||||
- **Vacay** — team vacation-day planner with public holiday integration.
|
||||
- **Journey** — cross-trip travel narrative with entries, contributors, and share links.
|
||||
|
||||
## Behavioral rules
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getNotifications } from '../services/inAppNotifications';
|
||||
import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { ADDON_IDS } from '../addons';
|
||||
import { canAccessJourney, getJourneyFull, listEntries, listJourneys } from '../services/journeyService';
|
||||
import { canRead, canReadTrips } from './scopes';
|
||||
|
||||
function parseId(value: string | string[]): number | null {
|
||||
@@ -381,4 +382,57 @@ export function registerResources(server: McpServer, userId: number, scopes: str
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Journey resources (Journey addon)
|
||||
if (isAddonEnabled(ADDON_IDS.JOURNEY) && canRead(scopes, 'journey')) {
|
||||
server.registerResource(
|
||||
'journeys',
|
||||
'trek://journeys',
|
||||
{ description: 'All journeys owned or contributed to by the current user', mimeType: 'application/json' },
|
||||
async (uri) => {
|
||||
const journeys = listJourneys(userId);
|
||||
return jsonContent(uri.href, journeys);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-detail',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}', { list: undefined }),
|
||||
{ description: 'Single journey with entries, contributors, and trip links', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const journey = getJourneyFull(id, userId);
|
||||
if (!journey) return accessDenied(uri.href);
|
||||
return jsonContent(uri.href, journey);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-entries',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}/entries', { list: undefined }),
|
||||
{ description: 'All entries in a journey (date, text, mood, linked trip)', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const j = canAccessJourney(id, userId);
|
||||
if (!j) return accessDenied(uri.href);
|
||||
const entries = listEntries(id, userId);
|
||||
return jsonContent(uri.href, entries);
|
||||
}
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'journey-contributors',
|
||||
new ResourceTemplate('trek://journeys/{journeyId}/contributors', { list: undefined }),
|
||||
{ description: 'Contributors (owners and collaborators) of a journey', mimeType: 'application/json' },
|
||||
async (uri, { journeyId }) => {
|
||||
const id = parseId(journeyId);
|
||||
if (id === null) return accessDenied(uri.href);
|
||||
const j = getJourneyFull(id, userId);
|
||||
if (!j) return accessDenied(uri.href);
|
||||
return jsonContent(uri.href, (j as any).contributors ?? []);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ export const SCOPES = {
|
||||
VACAY_WRITE: 'vacay:write',
|
||||
GEO_READ: 'geo:read',
|
||||
WEATHER_READ: 'weather:read',
|
||||
JOURNEY_READ: 'journey:read',
|
||||
JOURNEY_WRITE: 'journey:write',
|
||||
JOURNEY_SHARE: 'journey:share',
|
||||
} as const;
|
||||
|
||||
export type Scope = typeof SCOPES[keyof typeof SCOPES];
|
||||
@@ -64,6 +67,9 @@ export const SCOPE_INFO: Record<Scope, ScopeInfo> = {
|
||||
'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' },
|
||||
'geo:read': { label: 'Maps & geocoding', description: 'Search locations, resolve map URLs, and reverse geocode coordinates', group: 'Geo' },
|
||||
'weather:read': { label: 'Weather forecasts', description: 'Fetch weather forecasts for trip locations and dates', group: 'Weather' },
|
||||
'journey:read': { label: 'View journeys', description: 'Read journeys, entries, and contributor list', group: 'Journey' },
|
||||
'journey:write': { label: 'Manage journeys', description: 'Create, update, and delete journeys and their entries', group: 'Journey' },
|
||||
'journey:share': { label: 'Manage journey links', description: 'Create, update, and revoke public share links for journeys', group: 'Journey' },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,6 +107,12 @@ export function canShareTrips(scopes: string[] | null): boolean {
|
||||
return scopes.includes('trips:share');
|
||||
}
|
||||
|
||||
/** journey:share is a separate scope for managing public share links for journeys */
|
||||
export function canShareJourneys(scopes: string[] | null): boolean {
|
||||
if (!scopes) return true;
|
||||
return scopes.includes('journey:share');
|
||||
}
|
||||
|
||||
export function validateScopes(requestedScopes: string[]): { valid: boolean; invalid: string[] } {
|
||||
const invalid = requestedScopes.filter(s => !ALL_SCOPES.includes(s as Scope));
|
||||
return { valid: invalid.length === 0, invalid };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { registerTodoTools } from './tools/todos';
|
||||
import { registerAssignmentTools } from './tools/assignments';
|
||||
import { registerJourneyTools } from './tools/journey';
|
||||
import { registerReservationTools } from './tools/reservations';
|
||||
import { registerTagTools } from './tools/tags';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
@@ -12,6 +13,7 @@ import { registerBudgetTools } from './tools/budget';
|
||||
import { registerPackingTools } from './tools/packing';
|
||||
import { registerCollabTools } from './tools/collab';
|
||||
import { registerTripTools } from './tools/trips';
|
||||
import { registerTransportTools } from './tools/transports';
|
||||
import { registerVacayTools } from './tools/vacay';
|
||||
import { registerMcpPrompts } from './tools/prompts';
|
||||
|
||||
@@ -40,6 +42,10 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
|
||||
registerCollabTools(server, userId, scopes);
|
||||
|
||||
registerTransportTools(server, userId, scopes);
|
||||
|
||||
registerJourneyTools(server, userId, scopes);
|
||||
|
||||
registerVacayTools(server, userId, scopes);
|
||||
|
||||
registerTodoTools(server, userId, scopes);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createBudgetItem, updateBudgetItem, deleteBudgetItem,
|
||||
@@ -94,6 +94,42 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
|
||||
|
||||
// --- BUDGET ADVANCED ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_budget_item_with_members',
|
||||
{
|
||||
description: 'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
total_price: z.number().nonnegative(),
|
||||
note: z.string().max(500).optional(),
|
||||
userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, name, category, total_price, note, userIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const hasMembers = userIds && userIds.length > 0;
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const item = createBudgetItem(tripId, { category, name, total_price, note });
|
||||
if (hasMembers) {
|
||||
return updateBudgetMembers(item.id, tripId, userIds!);
|
||||
}
|
||||
return { item };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result });
|
||||
if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result });
|
||||
return ok({ item: result });
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'set_budget_item_members',
|
||||
{
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
getDay, updateDay, validateAccommodationRefs,
|
||||
createDay, deleteDay,
|
||||
createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation,
|
||||
} from '../../services/dayService';
|
||||
import { createPlace } from '../../services/placeService';
|
||||
import {
|
||||
createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote,
|
||||
deleteNote as deleteDayNote, dayExists as dayNoteExists,
|
||||
@@ -112,6 +113,53 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'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.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
||||
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
||||
check_in: z.string().max(10).optional().describe('Check-in time e.g. "15:00"'),
|
||||
check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'),
|
||||
confirmation: z.string().max(100).optional(),
|
||||
accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'),
|
||||
},
|
||||
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 }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
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 };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
||||
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes });
|
||||
return { place, accommodation };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation });
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_accommodation',
|
||||
{
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
addContributor, addTripToJourney, canAccessJourney, createEntry, createJourney,
|
||||
deleteEntry, deleteJourney, getJourneyFull, getSuggestions, listEntries,
|
||||
listJourneys, listUserTrips, removeContributor, removeTripFromJourney,
|
||||
reorderEntries, updateContributorRole, updateEntry, updateJourney,
|
||||
updateJourneyPreferences,
|
||||
} from '../../services/journeyService';
|
||||
import {
|
||||
createOrUpdateJourneyShareLink, deleteJourneyShareLink, getJourneyShareLink,
|
||||
} from '../../services/journeyShareService';
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
|
||||
demoDenied, ok,
|
||||
} from './_shared';
|
||||
import { canRead, canShareJourneys, canWrite } from '../scopes';
|
||||
|
||||
function notFound(msg: string) {
|
||||
return { content: [{ type: 'text' as const, text: msg }], isError: true };
|
||||
}
|
||||
|
||||
export function registerJourneyTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return;
|
||||
|
||||
const R = canRead(scopes, 'journey');
|
||||
const W = canWrite(scopes, 'journey');
|
||||
const S = canShareJourneys(scopes);
|
||||
|
||||
// --- READ TOOLS ---
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_journeys',
|
||||
{
|
||||
description: 'List all journeys owned or contributed to by the current user.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const journeys = listJourneys(userId);
|
||||
return ok({ journeys });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_journey',
|
||||
{
|
||||
description: 'Get a full journey including entries, contributors, and linked trips.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
const journey = getJourneyFull(journeyId, userId);
|
||||
if (!journey) return notFound('Journey not found or access denied.');
|
||||
return ok({ journey });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_journey_entries',
|
||||
{
|
||||
description: 'List all entries in a journey.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
|
||||
const entries = listEntries(journeyId, userId);
|
||||
return ok({ entries });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_journey_contributors',
|
||||
{
|
||||
description: 'List all contributors (owner and collaborators) of a journey.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
const journey = getJourneyFull(journeyId, userId);
|
||||
if (!journey) return notFound('Journey not found or access denied.');
|
||||
return ok({ contributors: (journey as any).contributors ?? [] });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'get_journey_suggestions',
|
||||
{
|
||||
description: 'Get trip suggestions for creating a new journey (recently completed trips not yet in any journey).',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const trips = getSuggestions(userId);
|
||||
return ok({ trips });
|
||||
}
|
||||
);
|
||||
|
||||
if (R) server.registerTool(
|
||||
'list_journey_available_trips',
|
||||
{
|
||||
description: 'List all trips available to link to a journey.',
|
||||
inputSchema: {},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async () => {
|
||||
const trips = listUserTrips(userId);
|
||||
return ok({ trips });
|
||||
}
|
||||
);
|
||||
|
||||
// --- WRITE TOOLS ---
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_journey',
|
||||
{
|
||||
description: 'Create a new journey, optionally linking existing trips.',
|
||||
inputSchema: {
|
||||
title: z.string().min(1).max(200),
|
||||
subtitle: z.string().max(300).optional(),
|
||||
trip_ids: z.array(z.number().int().positive()).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ title, subtitle, trip_ids }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const journey = createJourney(userId, { title, subtitle, trip_ids });
|
||||
return ok({ journey });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_journey',
|
||||
{
|
||||
description: 'Update an existing journey\'s title, subtitle, cover, or status. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
subtitle: z.string().max(300).optional(),
|
||||
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId, title, subtitle, status }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const journey = updateJourney(journeyId, userId, { title, subtitle, status });
|
||||
if (!journey) return notFound('Journey not found or access denied.');
|
||||
return ok({ journey });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_journey',
|
||||
{
|
||||
description: 'Delete a journey. Owner only — this cannot be undone.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = deleteJourney(journeyId, userId);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'add_journey_trip',
|
||||
{
|
||||
description: 'Link a trip to a journey. Syncs skeleton entries for all places in the trip.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ journeyId, tripId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.');
|
||||
const success = addTripToJourney(journeyId, tripId, userId);
|
||||
return ok({ success });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'remove_journey_trip',
|
||||
{
|
||||
description: 'Unlink a trip from a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
tripId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ journeyId, tripId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = removeTripFromJourney(journeyId, tripId, userId);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'create_journey_entry',
|
||||
{
|
||||
description: 'Create a new entry in a journey.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Entry date (YYYY-MM-DD)'),
|
||||
title: z.string().max(300).optional(),
|
||||
story: z.string().optional(),
|
||||
entry_time: z.string().optional().describe('Time of day (e.g. "14:30")'),
|
||||
location_name: z.string().optional(),
|
||||
mood: z.string().optional(),
|
||||
sort_order: z.number().int().min(0).optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ journeyId, entry_date, title, story, entry_time, location_name, mood, sort_order }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const entry = createEntry(journeyId, userId, { entry_date, title, story, entry_time, location_name, mood, sort_order });
|
||||
if (!entry) return notFound('Journey not found or access denied.');
|
||||
return ok({ entry });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_journey_entry',
|
||||
{
|
||||
description: 'Update an existing journey entry.',
|
||||
inputSchema: {
|
||||
entryId: z.number().int().positive(),
|
||||
title: z.string().max(300).optional(),
|
||||
story: z.string().optional(),
|
||||
entry_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||
entry_time: z.string().optional(),
|
||||
mood: z.string().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ entryId, title, story, entry_date, entry_time, mood }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const entry = updateEntry(entryId, userId, { title, story, entry_date, entry_time, mood }, undefined);
|
||||
if (!entry) return notFound('Entry not found or access denied.');
|
||||
return ok({ entry });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'delete_journey_entry',
|
||||
{
|
||||
description: 'Delete a journey entry.',
|
||||
inputSchema: {
|
||||
entryId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ entryId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = deleteEntry(entryId, userId, undefined);
|
||||
if (!success) return notFound('Entry not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'reorder_journey_entries',
|
||||
{
|
||||
description: 'Reorder entries within a journey by providing the desired order of entry IDs.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
orderedIds: z.array(z.number().int().positive()),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId, orderedIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = reorderEntries(journeyId, userId, orderedIds, undefined);
|
||||
if (!success) return notFound('Journey not found, access denied, or entry IDs do not belong to this journey.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'add_journey_contributor',
|
||||
{
|
||||
description: 'Add a contributor to a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
targetUserId: z.number().int().positive(),
|
||||
role: z.enum(['editor', 'viewer']),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ journeyId, targetUserId, role }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = addContributor(journeyId, userId, targetUserId, role);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_journey_contributor_role',
|
||||
{
|
||||
description: 'Update the role of a journey contributor. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
targetUserId: z.number().int().positive(),
|
||||
role: z.enum(['editor', 'viewer']),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId, targetUserId, role }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = updateContributorRole(journeyId, userId, targetUserId, role);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'remove_journey_contributor',
|
||||
{
|
||||
description: 'Remove a contributor from a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
targetUserId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ journeyId, targetUserId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = removeContributor(journeyId, userId, targetUserId);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_journey_preferences',
|
||||
{
|
||||
description: 'Update per-user preferences for a journey (e.g. hide skeleton entries).',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
hide_skeletons: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId, hide_skeletons }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const result = updateJourneyPreferences(journeyId, userId, { hide_skeletons });
|
||||
if (!result) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
|
||||
// --- SHARE TOOLS ---
|
||||
|
||||
if (S) server.registerTool(
|
||||
'get_journey_share_link',
|
||||
{
|
||||
description: 'Get the current public share link for a journey. Returns null if none exists.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
const shareLink = getJourneyShareLink(journeyId);
|
||||
return ok({ shareLink });
|
||||
}
|
||||
);
|
||||
|
||||
if (S) server.registerTool(
|
||||
'create_journey_share_link',
|
||||
{
|
||||
description: 'Create or update the public share link for a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const shareLink = createOrUpdateJourneyShareLink(journeyId, userId, {});
|
||||
if (!shareLink) return notFound('Journey not found or access denied.');
|
||||
return ok({ shareLink });
|
||||
}
|
||||
);
|
||||
|
||||
if (S) server.registerTool(
|
||||
'delete_journey_share_link',
|
||||
{
|
||||
description: 'Revoke the public share link for a journey. Owner only.',
|
||||
inputSchema: {
|
||||
journeyId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ journeyId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
const success = deleteJourneyShareLink(journeyId, userId);
|
||||
if (!success) return notFound('Journey not found or access denied.');
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { findByIata, searchAirports } from '../../services/airportService';
|
||||
import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService';
|
||||
import { getWeather, getDetailedWeather } from '../../services/weatherService';
|
||||
import {
|
||||
@@ -110,4 +111,38 @@ export function registerMapsWeatherTools(server: McpServer, userId: number, scop
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// --- AIRPORTS ---
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'search_airports',
|
||||
{
|
||||
description: 'Search for airports by name, city, or IATA code. Returns matching airports with IATA code, name, city, country, coordinates, and timezone. Use before create_transport (flight) to get the correct IATA code and timezone for endpoints.',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(200).describe('Airport name, city, or IATA code (e.g. "zurich", "ZRH", "charles de gaulle")'),
|
||||
limit: z.number().int().min(1).max(50).optional().default(10),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
const airports = searchAirports(query, limit ?? 10);
|
||||
return ok({ airports });
|
||||
}
|
||||
);
|
||||
|
||||
if (canGeo) server.registerTool(
|
||||
'get_airport',
|
||||
{
|
||||
description: 'Get a single airport by its IATA code. Returns name, city, country, coordinates, and timezone.',
|
||||
inputSchema: {
|
||||
iata: z.string().length(3).toUpperCase().describe('IATA airport code (e.g. "ZRH", "AMS", "CDG")'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
},
|
||||
async ({ iata }) => {
|
||||
const airport = findByIata(iata);
|
||||
if (!airport) return { content: [{ type: 'text' as const, text: 'Airport not found.' }], isError: true };
|
||||
return ok({ airport });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { canAccessTrip, db } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import { listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
|
||||
import { createAssignment, dayExists } from '../../services/assignmentService';
|
||||
import { onPlaceDeleted } from '../../services/journeyService';
|
||||
import { listCategories } from '../../services/categoryService';
|
||||
import { searchPlaces } from '../../services/mapsService';
|
||||
import {
|
||||
@@ -47,6 +49,48 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'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.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
dayId: z.number().int().positive().describe('Day to assign the place to'),
|
||||
name: z.string().min(1).max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
|
||||
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
|
||||
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
|
||||
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
|
||||
website: z.string().max(500).optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
|
||||
},
|
||||
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 }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
|
||||
try {
|
||||
const run = db.transaction(() => {
|
||||
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone });
|
||||
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
|
||||
return { place, assignment };
|
||||
});
|
||||
const result = run();
|
||||
safeBroadcast(tripId, 'place:created', { place: result.place });
|
||||
safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment });
|
||||
return ok(result);
|
||||
} catch {
|
||||
return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'update_place',
|
||||
{
|
||||
@@ -159,4 +203,57 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'import_places_from_url',
|
||||
{
|
||||
description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'),
|
||||
source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
},
|
||||
async ({ tripId, url, source }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const result = source === 'google-list'
|
||||
? await importGoogleList(String(tripId), url)
|
||||
: await importNaverList(String(tripId), url);
|
||||
|
||||
if ('error' in result) {
|
||||
return { content: [{ type: 'text' as const, text: result.error }], isError: true };
|
||||
}
|
||||
|
||||
for (const place of result.places) {
|
||||
safeBroadcast(tripId, 'place:created', { place });
|
||||
}
|
||||
return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
}
|
||||
);
|
||||
|
||||
if (W) server.registerTool(
|
||||
'bulk_delete_places',
|
||||
{
|
||||
description: 'Delete multiple places from a trip at once. Removes all day assignments for each place as well. Warn the user before calling this — it cannot be undone.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
placeIds: z.array(z.number().int().positive()).min(1).max(200),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, placeIds }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const deleted = deletePlacesMany(String(tripId), placeIds);
|
||||
for (const id of deleted) {
|
||||
safeBroadcast(tripId, 'place:deleted', { placeId: id });
|
||||
try { onPlaceDeleted(id); } catch {}
|
||||
}
|
||||
return ok({ deleted, count: deleted.length });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
server.registerTool(
|
||||
'create_reservation',
|
||||
{
|
||||
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.',
|
||||
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: {
|
||||
tripId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
@@ -78,12 +78,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop
|
||||
server.registerTool(
|
||||
'update_reservation',
|
||||
{
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.',
|
||||
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/other → use assignment_id to link to a day assignment.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
|
||||
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
||||
location: z.string().max(500).optional(),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
||||
import { z } from 'zod';
|
||||
import { canAccessTrip } from '../../db/database';
|
||||
import { isDemoUser } from '../../services/authService';
|
||||
import {
|
||||
createReservation, deleteReservation, getReservation, updateReservation,
|
||||
} from '../../services/reservationService';
|
||||
import { getDay } from '../../services/dayService';
|
||||
import {
|
||||
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok,
|
||||
} from './_shared';
|
||||
import { canWrite } from '../scopes';
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const;
|
||||
|
||||
const endpointSchema = z.array(z.object({
|
||||
role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'),
|
||||
sequence: z.number().int().min(0).describe('Order within the route (0-based)'),
|
||||
name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'),
|
||||
code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'),
|
||||
lat: z.number().optional(),
|
||||
lng: z.number().optional(),
|
||||
timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'),
|
||||
local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'),
|
||||
local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'),
|
||||
})).optional();
|
||||
|
||||
export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
||||
if (!canWrite(scopes, 'reservations')) return;
|
||||
|
||||
server.registerTool(
|
||||
'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.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']),
|
||||
title: z.string().min(1).max(200),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().default('pending'),
|
||||
start_day_id: z.number().int().positive().optional().describe('Departure day'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'),
|
||||
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||
endpoints: endpointSchema,
|
||||
needs_review: z.boolean().optional(),
|
||||
},
|
||||
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 }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
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 };
|
||||
|
||||
const { reservation } = createReservation(tripId, {
|
||||
title,
|
||||
type,
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
location: undefined,
|
||||
confirmation_number,
|
||||
notes,
|
||||
day_id: start_day_id,
|
||||
end_day_id: end_day_id ?? start_day_id,
|
||||
status: status ?? 'pending',
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review,
|
||||
});
|
||||
safeBroadcast(tripId, 'reservation:created', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'update_transport',
|
||||
{
|
||||
description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
type: z.enum(['flight', 'train', 'car', 'cruise']).optional(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
status: z.enum(['pending', 'confirmed', 'cancelled']).optional(),
|
||||
start_day_id: z.number().int().positive().optional().describe('Departure day'),
|
||||
end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'),
|
||||
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'),
|
||||
reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'),
|
||||
confirmation_number: z.string().max(100).optional(),
|
||||
notes: z.string().max(1000).optional(),
|
||||
metadata: z.record(z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'),
|
||||
endpoints: endpointSchema,
|
||||
needs_review: z.boolean().optional(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
},
|
||||
async ({ tripId, reservationId, 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 (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
|
||||
const existing = getReservation(reservationId, tripId);
|
||||
if (!existing) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
|
||||
|
||||
const resolvedType = type ?? existing.type;
|
||||
if (!(TRANSPORT_TYPES as readonly string[]).includes(resolvedType))
|
||||
return { content: [{ type: 'text' as const, text: 'Reservation is not a transport type. Use update_reservation instead.' }], isError: true };
|
||||
|
||||
if (start_day_id && !getDay(start_day_id, tripId))
|
||||
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
||||
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 };
|
||||
|
||||
const { reservation } = updateReservation(reservationId, tripId, {
|
||||
title,
|
||||
type,
|
||||
reservation_time,
|
||||
reservation_end_time,
|
||||
confirmation_number,
|
||||
notes,
|
||||
day_id: start_day_id,
|
||||
end_day_id,
|
||||
status,
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review,
|
||||
}, existing);
|
||||
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
||||
return ok({ reservation });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
'delete_transport',
|
||||
{
|
||||
description: 'Delete a transport booking from a trip.',
|
||||
inputSchema: {
|
||||
tripId: z.number().int().positive(),
|
||||
reservationId: z.number().int().positive(),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
},
|
||||
async ({ tripId, reservationId }) => {
|
||||
if (isDemoUser(userId)) return demoDenied();
|
||||
if (!canAccessTrip(tripId, userId)) return noAccess();
|
||||
const { deleted } = deleteReservation(reservationId, tripId);
|
||||
if (!deleted) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true };
|
||||
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
|
||||
return ok({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -257,6 +257,18 @@ router.post('/:id/entries', authenticate, (req: Request, res: Response) => {
|
||||
res.status(201).json(entry);
|
||||
});
|
||||
|
||||
router.put('/:id/entries/reorder', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const orderedIds = (req.body || {}).orderedIds;
|
||||
if (!Array.isArray(orderedIds) || !orderedIds.every(id => Number.isFinite(Number(id)))) {
|
||||
return res.status(400).json({ error: 'orderedIds must be an array of numbers' });
|
||||
}
|
||||
if (!svc.reorderEntries(Number(req.params.id), authReq.user.id, orderedIds.map(Number), req.headers['x-socket-id'] as string)) {
|
||||
return res.status(403).json({ error: 'Not allowed' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Contributors ─────────────────────────────────────────────────────────
|
||||
|
||||
router.post('/:id/contributors', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
@@ -598,6 +598,31 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Reorder entries (typically within a single day). Caller passes the new
|
||||
// desired order of ids; each entry's sort_order is set to its index in the
|
||||
// array. Only entries owned by this journey are accepted.
|
||||
export function reorderEntries(journeyId: number, userId: number, orderedIds: number[], sid?: string): boolean {
|
||||
if (!canEdit(journeyId, userId)) return false;
|
||||
if (!orderedIds.length) return true;
|
||||
|
||||
const placeholders = orderedIds.map(() => '?').join(',');
|
||||
const rows = db
|
||||
.prepare(`SELECT id FROM journey_entries WHERE id IN (${placeholders}) AND journey_id = ?`)
|
||||
.all(...orderedIds, journeyId) as { id: number }[];
|
||||
if (rows.length !== orderedIds.length) return false;
|
||||
|
||||
const now = ts();
|
||||
const update = db.prepare('UPDATE journey_entries SET sort_order = ?, updated_at = ? WHERE id = ?');
|
||||
const tx = db.transaction(() => {
|
||||
orderedIds.forEach((id, index) => update.run(index, now, id));
|
||||
db.prepare('UPDATE journeys SET updated_at = ? WHERE id = ?').run(now, journeyId);
|
||||
});
|
||||
tx();
|
||||
|
||||
broadcastJourneyEvent(journeyId, 'journey:entries:reordered', { orderedIds }, sid);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deleteEntry(entryId: number, userId: number, sid?: string): boolean {
|
||||
const entry = db.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as JourneyEntry | undefined;
|
||||
if (!entry) return false;
|
||||
|
||||
@@ -94,8 +94,22 @@ describe('Photo endpoint auth', () => {
|
||||
});
|
||||
|
||||
describe('Force HTTPS redirect', () => {
|
||||
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests', async () => {
|
||||
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests on non-health paths', async () => {
|
||||
// createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
try {
|
||||
httpsApp = createApp();
|
||||
} finally {
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/addons')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
});
|
||||
|
||||
it('MISC-008 — FORCE_HTTPS does not redirect /api/health (probes must reach it over HTTP)', async () => {
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
try {
|
||||
@@ -106,7 +120,8 @@ describe('Force HTTPS redirect', () => {
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
|
||||
|
||||
@@ -64,17 +64,17 @@ async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool: create_reservation', () => {
|
||||
it('creates a basic flight reservation', async () => {
|
||||
it('creates a basic reservation', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'create_reservation',
|
||||
arguments: { tripId: trip.id, title: 'Flight to Rome', type: 'flight' },
|
||||
arguments: { tripId: trip.id, title: 'Eiffel Tower Tour', type: 'tour' },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.reservation.title).toBe('Flight to Rome');
|
||||
expect(data.reservation.type).toBe('flight');
|
||||
expect(data.reservation.title).toBe('Eiffel Tower Tour');
|
||||
expect(data.reservation.type).toBe('tour');
|
||||
expect(data.reservation.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user